8751 lines
373 KiB
Rust
8751 lines
373 KiB
Rust
//! 运行时集成回归与示例验收。测试经真实 Aster 前端、编译器、VM 和展示协议执行,不是手工伪造布局结果。
|
||
//! 覆盖共享对象/独立 Mount、源覆盖、绑定、窗口、任务、取消、二维/三维投影及像素。局部辅助函数负责取统计值、收敛刷新和构造可重复输入。
|
||
//! 阅读时先看每个测试提交的 Endor 程序,再看后续变更与最终断言;编译成功、回调成功、文档发布成功是不同检查点。
|
||
//!
|
||
//! Rust 测试框架运行各 #[test],ReplSession 的测试同步入口负责排空实际 layout/paint,断言读取已完成文档。
|
||
//! r# 原始字符串中的 Endor 是验收夹具,保持其源码/行号不变;Rust 注释解释夹具意图和随后观察的行为。
|
||
use super::*;
|
||
use crate::document::{NamespaceState, RenderCommand};
|
||
|
||
// 一次性模块执行/错误保留测试共用的真实示例,不在测试中另造只为通过断言的近似模块。
|
||
const EXAMPLE: &str = include_str!("../../../examples/hello.endor");
|
||
// 页面脚本基线供多个源加载、替换、生命周期测试复用,include_str 在编译时嵌入。
|
||
const PAGE: &str = include_str!("../../../examples/page.endor");
|
||
|
||
/// 可选性能基准:分别测世界 UI 和普通三维场景在 1x/2x DPI 下的相机交互更新、变更绘制和同列表缓存绘制。
|
||
/// 默认 ignore,需在已确认的构建环境中以 release 单测试线程运行,避免其他测试干扰;下面命令仅为运行说明。
|
||
/// cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1
|
||
/// 该基准只打印耗时与计数,没有性能阈值或像素正确性断言;静态复审不等于已执行基准。
|
||
#[test]
|
||
#[ignore]
|
||
fn scene_interaction_performance() {
|
||
use std::time::Instant;
|
||
/// 对非空毫秒样本排序后返回均值、索引中位数和向上取整秩的 p95;调用方每组固定保留 30 次测量。
|
||
fn summary(mut values: Vec<f64>) -> String {
|
||
// total_cmp 提供稳定全序,下面按排序后的样本索引取统计值,不修改被测场景数据。
|
||
values.sort_by(f64::total_cmp);
|
||
format!(
|
||
"mean={:.3} p50={:.3} p95={:.3}ms",
|
||
values.iter().sum::<f64>() / values.len() as f64,
|
||
values[values.len() / 2],
|
||
values[(values.len() * 95).div_ceil(100) - 1]
|
||
)
|
||
}
|
||
// 每个场景/DPI 组合创建全新会话与像素缓冲,避免上一组合的相机状态和缓存直接污染下一组。
|
||
for (name, source) in [
|
||
("world-ui", include_str!("../../../examples/world-ui.endor")),
|
||
("scene3d", include_str!("../../../examples/scene3d.endor")),
|
||
] {
|
||
// 每个场景分别使用两种 DPI,比较相同逻辑布局在不同物理像素规模下的真实交互成本。
|
||
for dpi in [1., 2.] {
|
||
// 逻辑视口保持 1024×800,后面仅物理像素倍率变化,避免混淆 DPI 与场景布局大小。
|
||
let (mut session, mut document) = ReplSession::load_with_viewport(
|
||
source,
|
||
"app.main",
|
||
1,
|
||
988,
|
||
Some(crate::layout::Size {
|
||
width: 1024.,
|
||
height: 800.,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 软件输出尺寸按逻辑大小乘 DPI;同一组合复用像素缓冲,不把分配计入每帧绘制时间。
|
||
let (width, height) = ((1024. * dpi) as u32, (800. * dpi) as u32);
|
||
// u32 像素列表由真实软件渲染器覆盖,不用模拟绘图计数代替实际工作量。
|
||
let mut pixels = vec![0; (width * height) as usize];
|
||
// 三种样本分别统计派发/刷新、变化列表绘制和同列表再次绘制,不把它们混为一个延迟。
|
||
let mut updates = Vec::new();
|
||
// 相机刚变化后首次绘制耗时,和 source 更新/几何发布分开采样。
|
||
let mut draws = Vec::new();
|
||
// 同一 DisplayList 的第二次绘制,用于观察缓存复用路径,不再推进源程序。
|
||
let mut cached = Vec::new();
|
||
// 连续做 33 轮,前三轮预热;每轮使用最新发布版本构造事件,避免基准测到过期输入拒绝。
|
||
for sample in 0..33 {
|
||
// 每次从最新帧找原始三维视口,身份与版本一致才测到有效 orbit 而非快速过期拒绝。
|
||
let target = document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.scene3d_viewport)
|
||
.unwrap();
|
||
// 选当前帧第一个三维视口,小幅 orbit;带中心锚点使世界面板等坐标映射路径也有完整输入。
|
||
let input = crate::events::Input {
|
||
window: 1,
|
||
session: document.frame.session,
|
||
layout_version: document.frame.layout_version,
|
||
mount: target.id,
|
||
kind: "scene-orbit".into(),
|
||
point: Some([target.x + target.width / 2., target.y + target.height / 2.]),
|
||
delta: Some([1., 0.]),
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
};
|
||
// update 只计派发与完整文档刷新;display_list 构造在后面的绘制计时范围之外。
|
||
let start = Instant::now();
|
||
document = session.dispatch(&input).unwrap().document.unwrap();
|
||
// 统一转毫秒,sample>=3 后才保存预热之外的数值。
|
||
let update = start.elapsed().as_secs_f64() * 1000.;
|
||
// 显示列表构造不包含在下面 render 计时里,避免把两种阶段成本混为绘制耗时。
|
||
let list = document.display_list(dpi).unwrap();
|
||
// 单独计首次变化帧的像素绘制时间。
|
||
let start = Instant::now();
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&list,
|
||
&mut pixels,
|
||
width,
|
||
height,
|
||
)
|
||
.unwrap();
|
||
// 第一次 render 测相机已变化时的绘制;立即用同一列表再画一次,单独统计缓存重用路径。
|
||
let draw = start.elapsed().as_secs_f64() * 1000.;
|
||
// 不改变 list/像素尺寸直接再次 render,观察缓存命中绘制,不再发第二次相机输入。
|
||
let start = Instant::now();
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&list,
|
||
&mut pixels,
|
||
width,
|
||
height,
|
||
)
|
||
.unwrap();
|
||
// 三类样本按同一轮配对保存,仅舍弃预热数据,不改变真实执行的相机操作。
|
||
if sample >= 3 {
|
||
updates.push(update);
|
||
draws.push(draw);
|
||
cached.push(start.elapsed().as_secs_f64() * 1000.);
|
||
}
|
||
}
|
||
println!(
|
||
"PERF {name} dpi={dpi} update {} changed_draw {} cached_draw {}",
|
||
summary(updates),
|
||
summary(draws),
|
||
summary(cached)
|
||
);
|
||
// 额外打印派生工作量指标辅助解释耗时,不把这些查询本身算进上面的三类样本。
|
||
for stat in ["measured", "arranged", "snapshotVisited"] {
|
||
println!(
|
||
"PERFSTAT {name} {stat}={}",
|
||
session
|
||
.execute(&format!("ui.stats().{stat}"), "app.main")
|
||
.unwrap()
|
||
.value
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 源 measure 直接读取 mount.layoutVersion 并将其作为宽度。
|
||
/// 只使 paint 失效后仍必须重新测量;通过新版本宽度和零缓存命中,防止把真实 Mount 读取错误缓存。
|
||
fn actual_mount_version_reads_still_bypass_measurement_cache() {
|
||
// measure 显式读取 Mount.layoutVersion,将宿主出现版本变成真实依赖,不能按静态对象结果复用。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r#"
|
||
class Versioned: RectBlock {
|
||
function measure(self: Versioned,mount: Mount,constraints: Constraints): MeasureResult {
|
||
return MeasureResult(mount.layoutVersion,20)
|
||
}
|
||
}
|
||
rect=Versioned(80,20)
|
||
page=Page()
|
||
page.render(rect)
|
||
"#,
|
||
"app.main",
|
||
1,
|
||
241,
|
||
)
|
||
.unwrap();
|
||
// 即使只请求 paint 失效,版本依赖仍要求重新测量,不能无条件沿用上轮宽度。
|
||
let next = session
|
||
.execute("rect.invalidate(\"paint\")", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(next.frame.layout_version > initial.frame.layout_version);
|
||
assert!(next.frame.commands.iter().any(|command|matches!(command,RenderCommand::RoundedRect{width,..} if *width==next.frame.layout_version as f32)));
|
||
assert_eq!(source_measure_stat(&mut session, "sourceMeasureHits"), 0);
|
||
assert!(source_measure_stat(&mut session, "sourceMeasureMisses") >= 2);
|
||
}
|
||
|
||
#[test]
|
||
/// 加载布局协商示例,验证同一段落两个出现位置分别获得 280/360 的宽度。
|
||
/// 加宽第一个出现位置后高度和后继 y 减小,第二个仍保持 360,且全部 Mount ID 不变。
|
||
fn source_allocations_and_measurement_limits_settle_before_publication() {
|
||
// 同一 paragraph 在两个约束下展示,验收源布局协商而非给两个独立对象写固定宽度。
|
||
let (mut session, initial) = ReplSession::load_with_viewport(
|
||
include_str!("../../../examples/layout-negotiation.endor"),
|
||
"app.main",
|
||
1,
|
||
240,
|
||
Some(crate::layout::Size {
|
||
width: 850.0,
|
||
height: 700.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 保存所有出现身份,后面加宽只允许改变几何,不能销毁重建整棵 Mount 树。
|
||
let ids = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.map(|m| m.id)
|
||
.collect::<Vec<_>>();
|
||
// 通过源 Mount API 读取第一个出现实际布局宽,验证不是只在绘图命令中临时缩放。
|
||
let width0 = session
|
||
.execute("paragraph.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value;
|
||
// 第二个出现属于同一业务对象,但有独立父约束,初始应为 360。
|
||
let width1 = session
|
||
.execute("paragraph.mounts().at(1).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value;
|
||
assert_eq!(width0, "280");
|
||
assert_eq!(width1, "360");
|
||
// 较窄约束使段落多行,记录原高度用于加宽后的 reflow 比较。
|
||
let height = session
|
||
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse::<f32>()
|
||
.unwrap();
|
||
// tail 在段落之后,段落高度变化必须传到后继 y,而不只是更新该段落绘制边界。
|
||
let y = session
|
||
.execute("tail.mounts().at(0).bounds().y", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse::<f32>()
|
||
.unwrap();
|
||
assert!(height > 20.0);
|
||
assert!(y >= height + 12.0);
|
||
assert!(source_measure_stat(&mut session, "layoutPasses") >= 2);
|
||
// 源按钮修改第一处宽度,再由正常刷新收敛和发布,不能直接从测试覆盖 Frame 几何。
|
||
let changed = session
|
||
.execute("wide.click()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert_eq!(
|
||
changed
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.map(|m| m.id)
|
||
.collect::<Vec<_>>(),
|
||
ids
|
||
);
|
||
// 变量名 wider 指加宽后的高度;文本行数减少时这个值应小于原 height。
|
||
let wider = session
|
||
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse::<f32>()
|
||
.unwrap();
|
||
// 同步观察 tail 上移,证明父布局已在发布前消费新的段落高度。
|
||
let moved = session
|
||
.execute("tail.mounts().at(0).bounds().y", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse::<f32>()
|
||
.unwrap();
|
||
assert!(wider < height && moved < y);
|
||
assert_eq!(
|
||
session
|
||
.execute("paragraph.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"560"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("paragraph.mounts().at(1).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"360"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 源 arrange 把子项宽高不断交换,构造不收敛的布局状态。
|
||
/// 加载必须报告 cycle,不能发布某个偶然中间态或依靠固定次数静默停止。
|
||
fn alternating_source_allocations_are_rejected_as_a_cycle() {
|
||
// 源 arrange 在父宽高之间交替指定孩子尺寸,固定点不收敛,应作为 cycle 拒绝而非任意截断。
|
||
let source = r#"
|
||
class Alternating: Column {
|
||
function arrange(self: Alternating,mount: Mount,geometry: Bounds): Arrangement {
|
||
result=Column.arrange(self,mount,geometry)
|
||
result.children.at(0).width=geometry.height
|
||
result.children.at(0).height=geometry.width
|
||
return result
|
||
}
|
||
}
|
||
page=Page()
|
||
page.render(Alternating(0,0,RectBlock(120,60)))
|
||
"#;
|
||
// 从真实初始布局收敛得到错误,断言消息以区分预算耗尽和明确循环检测。
|
||
let error = ReplSession::load(source, "app.main", 1, 1).err().unwrap();
|
||
assert!(
|
||
error.to_string().contains("did not converge (cycle)"),
|
||
"{error:#}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 源 paint 只提供 wrapWidth,省略成对需要的 wrapHeight。
|
||
/// 检查命令解码明确拒绝不完整文字边界,而非生成测量/绘制不一致的帧。
|
||
fn source_text_paint_rejects_incomplete_wrap_bounds() {
|
||
// 只写 wrapWidth 不写 wrapHeight,构造解码层必须拒绝的半套文本边界,不能容错生成另一种命令。
|
||
let source = r##"
|
||
class Invalid: TextBlock {
|
||
function paint(self: Invalid,mount: Mount,geometry: Bounds): DisplayList {
|
||
command=PaintCommand()
|
||
command.kind="text"
|
||
command.x=0
|
||
command.y=0
|
||
command.text="invalid"
|
||
command.size=1
|
||
command.color="#000000"
|
||
command.wrapWidth=40
|
||
return DisplayList(ObjectList(command))
|
||
}
|
||
}
|
||
page=Page()
|
||
page.render(Invalid("invalid"))
|
||
"##;
|
||
// 源类型与函数合法,失败应准确指向遗漏的 wrapHeight,证明进入了实际 PaintCommand 解码。
|
||
let error = ReplSession::load(source, "app.main", 1, 1).err().unwrap();
|
||
assert!(error.to_string().contains("wrapHeight"), "{error:#}");
|
||
}
|
||
|
||
#[test]
|
||
/// 分别对原生 TextBlock 和委托原生方法的源子类运行相同测试。
|
||
/// 共享文字在不同内边距下独立换行;视口变宽只更新几何、保留 Mount,直接测量和 setText 也应同步反映尺寸。
|
||
fn wrapped_text_tracks_each_occurrence_width_resize_and_sibling_geometry() {
|
||
// 长中文段落迫使换行,不能靠一个短词恰好装得下而漏测父约束对自然高度的影响。
|
||
let text = "中文段落随着宽度变化而换行,后面的对象跟随段落高度移动。".repeat(8);
|
||
// 原生文字与源委托覆盖分别执行整套测试,检查两条入口在共享出现/重排时保持一致。
|
||
for custom in [false, true] {
|
||
// 原生 TextBlock 与源委托子类应走同一测量/绘制语义,分别执行避免共享一个偶然缓存结果。
|
||
let constructor = if custom { "Wrapped" } else { "TextBlock" };
|
||
// 相同 paragraph 放到不同内边距容器,两个出现必须分别获得可用宽度和换行高度。
|
||
let source = format!(
|
||
r#"
|
||
class Wrapped: TextBlock {{
|
||
function measure(self: Wrapped,mount: Mount,constraints: Constraints): MeasureResult {{return TextBlock.measure(self,mount,constraints)}}
|
||
function paint(self: Wrapped,mount: Mount,geometry: Bounds): DisplayList {{return TextBlock.paint(self,mount,geometry)}}
|
||
}}
|
||
paragraph={constructor}("{text}")
|
||
tail=TextBlock("tail")
|
||
page=Page()
|
||
page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph)))
|
||
"#
|
||
);
|
||
// 初始逻辑宽 240、高度充足,测到换行而非可视裁剪导致的“只看到几行”。
|
||
let (mut session, initial) = ReplSession::load_with_viewport(
|
||
&source,
|
||
"app.main",
|
||
1,
|
||
230,
|
||
Some(crate::layout::Size {
|
||
width: 240.0,
|
||
height: 2000.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// resize 后身份必须保持,缓存/几何更新不应改已捕获的 Mount ID。
|
||
let ids: Vec<_> = initial.frame.mounts.iter().map(|m| m.id).collect();
|
||
// 只取实际段落文本命令,排除 tail/其他文本,读取 wrap_bounds 的宽与高。
|
||
let commands: Vec<_> = initial
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.filter_map(|command| match command {
|
||
RenderCommand::Text {
|
||
value,
|
||
wrap_bounds: Some(bounds),
|
||
..
|
||
} if value == &text => Some(*bounds),
|
||
// 非目标段落或无换行边界命令不参与这组独立出现比较。
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(commands.len(), 2);
|
||
// 输入 viewport 已是页面可用区域,不再隐式扣侧栏。
|
||
// 第二个出现位置左右各有 50px 内边距,因此宽度比第一个少 100px。
|
||
assert_eq!(commands[0][0], 240.0);
|
||
assert_eq!(commands[1][0], 140.0);
|
||
assert!(commands[1][1] > commands[0][1]);
|
||
// 文本绘制 y 也需跟随段落自然高度,不能只有 Mount 的虚拟几何更新。
|
||
let tail = initial
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.find_map(|command| match command {
|
||
RenderCommand::Text { value, y, .. } if value == "tail" => Some(*y),
|
||
_ => None,
|
||
})
|
||
.unwrap();
|
||
assert!(tail >= commands[0][1] + 8.0);
|
||
// 只改变窗口逻辑宽,不重新执行 Endor 输入,验证 renderer/源协议在保留 VM 上重排。
|
||
let resized = session
|
||
.resize(Some(crate::layout::Size {
|
||
width: 400.0,
|
||
height: 2000.0,
|
||
}))
|
||
.unwrap()
|
||
.unwrap();
|
||
assert_eq!(
|
||
ids,
|
||
resized
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.map(|m| m.id)
|
||
.collect::<Vec<_>>()
|
||
);
|
||
// 两个父级仍相差 100 内边距,但分别在新宽度下重测,不能共享一个固定 wrapWidth。
|
||
let widths: Vec<_> = resized
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.filter_map(|command| match command {
|
||
RenderCommand::Text {
|
||
value,
|
||
wrap_bounds: Some(bounds),
|
||
..
|
||
} if value == &text => Some(bounds[0]),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(widths, [400.0, 300.0]);
|
||
// 比较发布 Text 命令的 wrap_bounds 高度,验证测量/投影随宽度更新;本测试此处没有光栅化像素。
|
||
let heights: Vec<_> = resized
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.filter_map(|command| match command {
|
||
RenderCommand::Text {
|
||
value,
|
||
wrap_bounds: Some(bounds),
|
||
..
|
||
} if value == &text => Some(bounds[1]),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(heights[0] < commands[0][1]);
|
||
// 源代码显式在更窄 80 约束下 measure,验证协议返回高度也与 Frame 的宽约束相关。
|
||
let direct = session
|
||
.execute(
|
||
"paragraph.measure(paragraph.mounts().at(0),Constraints(0,0,80,2000)).height",
|
||
"app.main",
|
||
)
|
||
.unwrap()
|
||
.value
|
||
.parse::<f32>()
|
||
.unwrap();
|
||
assert!(direct > heights[0]);
|
||
session
|
||
.execute("paragraph.setText(\"short\")", "app.main")
|
||
.unwrap();
|
||
// 短文本恢复单行,但同业务对象的两个出现仍都在,不能用替换对象逃避共享布局更新。
|
||
assert_eq!(
|
||
session
|
||
.execute("paragraph.mounts().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"20"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 加载回调签名示例,逐项尝试不匹配的事件/绑定/任务回调及参数个数。
|
||
/// 注册期错误不能使会话 fault;合法零参数与 Event 参数回调均可执行,最后检查底层方法实际 arity。
|
||
fn native_signatures_validate_registration_and_deliver_the_event_argument() {
|
||
// 使用带零参/单 Event 回调的真实示例,后续非法注册与合法分发都在同一份活 globals 中执行。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/native-signatures.endor"),
|
||
"app.main",
|
||
1,
|
||
220,
|
||
)
|
||
.unwrap();
|
||
// 枚举非法注册/调用,每轮都检查错误可恢复且旧文档未被标为故障。
|
||
// 每个非法注册/调用独立检查恢复性,覆盖错误回调与显式参数数目。
|
||
for invalid in [
|
||
r#"withEvent.on("click", ui.textOf)"#,
|
||
r#"withEvent.on("click", Page)"#,
|
||
r#"label.bind("text", received.set)"#,
|
||
r#"ui.after(ui,"bad",60000,received.set)"#,
|
||
"count.increment(1)",
|
||
] {
|
||
// 逐个变坏回调种类或参数数量,错误应在注册/调用边界拒绝,健康运行时仍可继续接受输入。
|
||
assert!(session.execute(invalid, "app.main").is_err(), "{invalid}");
|
||
assert!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.fault
|
||
.is_none()
|
||
);
|
||
}
|
||
// 合法单 Event 回调把 kind 保存到模型,渲染文字也应读取到 click,覆盖实际参数交付。
|
||
let document = session
|
||
.execute("withEvent.click()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(
|
||
document.frame.commands.iter().any(
|
||
|command| matches!(command, RenderCommand::Text { value, .. } if value == "click")
|
||
)
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("received.value.kind", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
r#""click""#
|
||
);
|
||
session.execute("withoutEvent.click()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session.execute("count.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
assert!(session.poll_tasks().unwrap().is_none());
|
||
// 最后直接查 VM 元数据,检查绑定状态方法 set 的实际显式 arity,不只看错误字符串。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// received 是全局里真实状态对象,所有权仍由 globals 持有,这里只读取其句柄。
|
||
let Value::Object(received) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("received".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("state")
|
||
};
|
||
// set 已捕获 receiver,只应接受一个新值,不能额外把 owner 计入未绑定参数。
|
||
let Value::Object(set) = session.candidate.vm.get_attr(received, "set").unwrap() else {
|
||
panic!("set")
|
||
};
|
||
assert_eq!(
|
||
session.candidate.vm.call_arity(set).unwrap(),
|
||
Some(endor_core::CallArity::exact(1))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 尝试错误字段赋值与构造,检查声明字段契约在原生分配副作用之前验证。
|
||
/// 已有值和窗口页面数量必须保持不变,随后合法点击仍能递增并发布文字。
|
||
fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_values() {
|
||
// 声明字段具有真实语言注解,错误数字/字符串构造应在原生分配副作用之前失败。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/field-contracts.endor"),
|
||
"app.main",
|
||
1,
|
||
210,
|
||
)
|
||
.unwrap();
|
||
// 同一会话依次尝试字段写入和两类构造错误,便于后续核对原值与页面资源没有副作用。
|
||
for invalid in [
|
||
r#"counter.value = "wrong""#,
|
||
r#"CounterText("bad", "wrong")"#,
|
||
"Model(42)",
|
||
] {
|
||
// 三种错误分别覆盖已有字段写入、原生子类构造与普通类构造,均不能停掉健康域。
|
||
// 保留真实诊断检查 field,避免一个无关语法错误也被测试当作字段类型检查通过。
|
||
let error = session.execute(invalid, "app.main").err().unwrap();
|
||
assert!(error.to_string().contains("field"), "{error:#}");
|
||
assert!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.fault
|
||
.is_none()
|
||
);
|
||
}
|
||
assert_eq!(
|
||
session.execute("counter.value", "app.main").unwrap().value,
|
||
"9"
|
||
);
|
||
session
|
||
.execute("class CheckedPage: Page { count: Number }", "app.main")
|
||
.unwrap();
|
||
// 用页面数量作为构造副作用基线,错误 CheckedPage 字段不能偷偷把新页注册到窗口。
|
||
let pages = session
|
||
.execute("ui.window().pages().count", "app.main")
|
||
.unwrap()
|
||
.value;
|
||
assert!(
|
||
session
|
||
.execute(r#"CheckedPage("wrong")"#, "app.main")
|
||
.is_err()
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.window().pages().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
pages
|
||
);
|
||
// 前面错误不应破坏活值/回调,合法 click 仍把 9 递增成 10 并反映到实际文字命令。
|
||
let updated = session
|
||
.execute("button.click()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(
|
||
updated
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.any(|command| matches!(command, RenderCommand::Text { value, .. } if value == "10"))
|
||
);
|
||
assert_eq!(
|
||
session.execute("counter.value", "app.main").unwrap().value,
|
||
"10"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 把 Numeric 视觉引用替换为 Textual,旧 alias 随共享转发指向新对象。
|
||
/// 字段检查必须按新类型执行;合法字符串赋值通过,数字写入和返回错误类型的绑定均被拒绝。
|
||
fn checked_fields_follow_shared_block_forwarding_and_binding_writes() {
|
||
// alias 保留原包装身份,block 替换到不同字段类型子类后,后续字段校验应沿共享转发找真实新类型。
|
||
let (mut session, _) = ReplSession::load(
|
||
r#"
|
||
class Numeric: TextBlock { value: Number }
|
||
class Textual: TextBlock { value: String }
|
||
block = Numeric("old", 9)
|
||
alias = block
|
||
page = Page()
|
||
page.render(block)
|
||
"#,
|
||
"app.main",
|
||
1,
|
||
211,
|
||
)
|
||
.unwrap();
|
||
session
|
||
.execute(r#"block = Textual("new", "kept")"#, "app.main")
|
||
.unwrap();
|
||
// 旧 Numeric 类型不能成为绕过新 Textual 字符串字段契约的入口。
|
||
assert!(session.execute("alias.value = 42", "app.main").is_err());
|
||
assert_eq!(
|
||
session.execute("alias.value", "app.main").unwrap().value,
|
||
r#""kept""#
|
||
);
|
||
session
|
||
.execute(r#"alias.value = "updated""#, "app.main")
|
||
.unwrap();
|
||
// 合法 alias 写入必须从 block 另一名字可见,证明并非复制一份独立字段存储。
|
||
assert_eq!(
|
||
session.execute("block.value", "app.main").unwrap().value,
|
||
r#""updated""#
|
||
);
|
||
// ui.viewport 返回对象,与 Textual.value 的 String 契约不符;此处仅断言绑定失败,没有检查失败后的订阅数量。
|
||
assert!(
|
||
session
|
||
.execute("alias.bind(\"value\", ui.viewport)", "app.main")
|
||
.is_err()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 在容器中间插入子项,检查新稳定槽、后继 y 变化及旧 Mount 身份保留。
|
||
/// 越界、负位置和自包含循环在修改前失败;移除新槽后旧出现位置仍有效。
|
||
fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_graphs() {
|
||
// 先建 head/tail 两条稳定组成边,中间插入新节点应只为新边分配 slot,而不改旧边身份。
|
||
let source = r#"head=TextBlock("head")
|
||
tail=TextBlock("tail")
|
||
list=Column(24,8,head,tail)
|
||
page=Page()
|
||
page.render(list)"#;
|
||
// 保存初始完整几何,后续所有比较沿同一持久 VM,不重载源码制造看似相同的出现。
|
||
let (mut session, initial) = ReplSession::load(source, "app.main", 1, 200).unwrap();
|
||
session
|
||
.execute("held=tail.mounts().at(0)", "app.main")
|
||
.unwrap();
|
||
// 从真实源 Mount 查询稳定 ID,插入后仍应对应同一 tail 出现,只改变位置。
|
||
let id = session
|
||
.execute("held.id", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse::<u64>()
|
||
.unwrap();
|
||
// 旧 y 来自插入前发布帧,以便与新帧的同身份出现对比。
|
||
let old = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.id == id)
|
||
.unwrap();
|
||
// 插入位置 1 与新 slot 2 故意不同,防止把顺序下标误当稳定组成身份。
|
||
let inserted = session
|
||
.execute("slot=list.insert(1,TextBlock(\"middle\"))", "app.main")
|
||
.unwrap();
|
||
assert_eq!(inserted.value, "2");
|
||
// 成功结构编辑应产生完整新文档,而非只返回一个槽号却未安排刷新。
|
||
let document = inserted.document.unwrap();
|
||
// 同 ID 在新帧仍存在,表示保留出现身份;其 y 增加证明父布局实际插入了中间孩子。
|
||
let new = document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.id == id)
|
||
.unwrap();
|
||
assert!(new.y > old.y);
|
||
assert_eq!(
|
||
session
|
||
.execute("tail.mounts().at(0).id", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
id.to_string()
|
||
);
|
||
// 表驱动覆盖位置边界与组合循环,合法插入后的旧 Mount 必须在全部失败尝试后仍有效。
|
||
for invalid in [
|
||
"list.insert(4,head)",
|
||
"list.insert(0,list)",
|
||
"list.insert(-1,head)",
|
||
] {
|
||
// 超出插入缝、将自身包含成环和负位置都应在写入组成前失败,不把整个 VM 标故障。
|
||
assert!(session.execute(invalid, "app.main").is_err());
|
||
assert!(!session.is_faulted());
|
||
}
|
||
assert_eq!(
|
||
session.execute("held.mounted", "app.main").unwrap().value,
|
||
"true"
|
||
);
|
||
session.execute("list.remove(slot)", "app.main").unwrap();
|
||
// 删除新边使用返回的稳定 slot,而非插入位置 1,旧 tail 仍保持同一 ID。
|
||
assert_eq!(
|
||
session
|
||
.execute("tail.mounts().at(0).id", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
id.to_string()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 先同窗重排再跨窗移动,确认调用 Page 源覆盖且保留 Page 业务身份。
|
||
/// 跨窗目标帧版本不匹配必须拒绝;合法迁移后旧 Mount 失效,新窗口页面表指向原 Page。
|
||
fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targets() {
|
||
// 示例同时提供多个 Page 和独立窗口,Page 源覆盖把迁移/重排调用次数写进模型计数。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/tab-placement.endor"),
|
||
"app.main",
|
||
1,
|
||
201,
|
||
)
|
||
.unwrap();
|
||
session.take_window_commands().unwrap();
|
||
// Tab ID 与 Page 对象/Mount ID 不同,输入在标签路径用 mount 字段携带标签稳定身份。
|
||
let page = initial.frame.tabs[0].id;
|
||
// 同窗插入缝 3 测向后移动时的下标修正,而不是把页面重新创建到列表末尾。
|
||
let input = crate::events::Input {
|
||
window: 1,
|
||
session: 201,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: page,
|
||
kind: "tab-drop".into(),
|
||
point: None,
|
||
press_point: None,
|
||
delta: None,
|
||
screen_position: None,
|
||
tab_drop: Some(crate::events::TabDrop {
|
||
window: 1,
|
||
layout_version: initial.frame.layout_version,
|
||
index: 3,
|
||
}),
|
||
};
|
||
session
|
||
.execute("held=list.mounts().at(0)", "app.main")
|
||
.unwrap();
|
||
// 同窗标签重排不应卸载内容,提前保留当前内容 Mount 以区分重排和跨窗迁移。
|
||
let mount = session.execute("held.id", "app.main").unwrap().value;
|
||
session.dispatch(&input).unwrap();
|
||
assert_eq!(
|
||
session.execute("calls.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("list.mounts().at(0).id", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
mount
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.window().pages().at(2)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
session.execute("first", "app.main").unwrap().value
|
||
);
|
||
// 第二步改为跨窗口目标,两端都采用各自最新发布版本,不因用错源版本误判迁移失败。
|
||
let input = crate::events::Input {
|
||
layout_version: session.documents[&1].frame.layout_version,
|
||
tab_drop: Some(crate::events::TabDrop {
|
||
window: 2,
|
||
layout_version: session.documents[&2].frame.layout_version,
|
||
index: 0,
|
||
}),
|
||
..input
|
||
};
|
||
// 只改坏目标版本,源端仍有效,验证目标校验不可被源窗口身份通过所替代。
|
||
let mut stale = input.clone();
|
||
stale.tab_drop.as_mut().unwrap().layout_version += 1;
|
||
assert!(!session.accepts_tab_drop(&stale));
|
||
assert!(session.dispatch(&stale).is_err());
|
||
assert!(!session.is_faulted());
|
||
session.dispatch(&input).unwrap();
|
||
// 合法跨窗会调用 moveToWindow 与 moveTab,业务 Page 身份不变,但旧出现应被卸载重建到目标窗。
|
||
assert_eq!(
|
||
session.execute("calls.value", "app.main").unwrap().value,
|
||
"3"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("first.window().id()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("other.pages().at(0)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
session.execute("first", "app.main").unwrap().value
|
||
);
|
||
assert_eq!(
|
||
session.execute("held.mounted", "app.main").unwrap().value,
|
||
"false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// Page.moveToWindow 覆盖只返回 self,模拟源程序拒绝跨窗移动。
|
||
/// 拖放不能强行改变窗口归属;随后 detach 使用独立入口,并排出带负桌面坐标的原生命令。
|
||
fn drop_respects_declined_source_moves_and_detach_queues_signed_desktop_position() {
|
||
// moveToWindow 源覆盖只返回 self 而不实际移动,宿主不能因为回调无错误就假定迁移已经发生。
|
||
let source = r#"class Declining: Page {
|
||
calls: Object
|
||
function moveToWindow(self: Declining, window: Window): Page { self.calls.increment() return self }
|
||
}
|
||
calls=ui.state(0)
|
||
page=Declining(calls)
|
||
page.render(TextBlock("source"))
|
||
other=Window()
|
||
target=Page()
|
||
target.render(TextBlock("target"))
|
||
target.open(other)"#;
|
||
// 初始命令先排空,最后只观察本次 detach 新产生的 MoveTo,而不是启动残留窗口命令。
|
||
let (mut session, initial) = ReplSession::load(source, "app.main", 1, 202).unwrap();
|
||
session.take_window_commands().unwrap();
|
||
// 目标副窗真实存在,但源覆盖拒绝移动后不应继续去重排那个无关窗口的其他页。
|
||
let mut input = crate::events::Input {
|
||
window: 1,
|
||
session: 202,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: initial.frame.tabs[0].id,
|
||
kind: "tab-drop".into(),
|
||
point: None,
|
||
press_point: None,
|
||
delta: None,
|
||
screen_position: None,
|
||
tab_drop: Some(crate::events::TabDrop {
|
||
window: 2,
|
||
layout_version: initial.windows[&2].frame.layout_version,
|
||
index: 0,
|
||
}),
|
||
};
|
||
session.dispatch(&input).unwrap();
|
||
assert_eq!(
|
||
session.execute("calls.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("page.window().id()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"1"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("other.pages().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"1"
|
||
);
|
||
// detach 是独立创建窗口入口,清掉 drop 元数据并添加负桌面坐标,覆盖多显示器布局。
|
||
input.kind = "tab-detach".into();
|
||
input.tab_drop = None;
|
||
input.screen_position = Some([-400, 120]);
|
||
input.layout_version = session.documents[&1].frame.layout_version;
|
||
session.dispatch(&input).unwrap();
|
||
// 命令只登记不实际操作 OS;新资源应为窗口 3,坐标仍为有符号 -400/120。
|
||
let commands = session.take_window_commands().unwrap();
|
||
assert!(commands.iter().any(|command| command.window == 3
|
||
&& command.action == crate::window::Action::MoveTo { x: -400, y: 120 }));
|
||
assert_eq!(
|
||
session
|
||
.execute("page.window().id()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"3"
|
||
);
|
||
}
|
||
|
||
/// 为异步文件测试写入唯一临时 UTF-8 文件,再把路径交给闭包。
|
||
/// 进程号加原子序号防止并发冲突,Cleanup 析构保证断言 panic 时也尝试删除文件。
|
||
fn with_file_task_input(contents: &str, run: impl FnOnce(&std::path::Path)) {
|
||
// 只需唯一递增值,不依赖它同步其他内存,Relaxed 足以保证不同测试取到不同序号。
|
||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||
// 每个调用取不同后缀,嵌套 old/new 文件测试也不覆盖同一路径。
|
||
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
// 临时目录加 PID/序号只隔离测试文件,不从源模型或当前工作目录推断文件位置。
|
||
let path =
|
||
std::env::temp_dir().join(format!("tessera-text-task-{}-{id}.txt", std::process::id()));
|
||
// 将文件删除绑定到栈生命周期,避免测试提前失败时留下临时文件。
|
||
struct Cleanup(std::path::PathBuf);
|
||
impl Drop for Cleanup {
|
||
// 清理失败不再次 panic,避免覆盖原测试故障或在异常展开时二次崩溃。
|
||
fn drop(&mut self) {
|
||
// 若 provider 尚在关闭句柄或文件已被移除,最佳努力清理失败不应遮住原断言失败。
|
||
let _ = std::fs::remove_file(&self.0);
|
||
}
|
||
}
|
||
// 守卫必须在 run 之前创建,闭包 panic 时也随栈展开删除本例临时输入。
|
||
let _cleanup = Cleanup(path.clone());
|
||
std::fs::write(&path, contents).unwrap();
|
||
run(&path);
|
||
}
|
||
/// 生成 Loader 测试程序:readText 成功回调把 Task.result 写入未挂载的 target。
|
||
/// 路径先按 JSON 字符串转义再插入 Endor,tail 用于附加替换/取消等测试操作。
|
||
fn file_task_source(path: &std::path::Path, tail: &str) -> String {
|
||
// 使用 JSON 字符串转义 Windows/Unix 路径中的斜杠和引号,避免把路径内容拼成 Endor 语法。
|
||
let path = serde_json::to_string(&path.to_string_lossy()).unwrap();
|
||
format!(
|
||
"class Loader {{\n target: TextBlock\n task: Object\n function apply(self: Loader): Unit {{ self.target.text = self.task.result() }}\n}}\ntarget = TextBlock(\"waiting\")\nloader = Loader(target,ui.state(0))\npage = Page()\npage.render(TextBlock(\"target stays hidden\"))\nloader.task = ui.readText(loader,\"read\",{path},loader.apply)\n{tail}"
|
||
)
|
||
}
|
||
/// 直接从当前 VM globals 找到 loader.task,按托管类型的方法表调用 status。
|
||
/// 不通过 REPL execute 查询,避免状态观察自身引入一次输入、刷新或任务调度副作用。
|
||
fn file_task_state(session: &mut ReplSession) -> String {
|
||
// 直接查同一个持久 VM 的真实 globals,不执行一条额外 Console 输入改变 transcript/刷新轮次。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// loader 是测试程序保存任务的持有者,字典仍拥有它,这里只借身份定位 task 属性。
|
||
let Value::Object(loader) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("loader".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("loader");
|
||
};
|
||
// 即使 Task 已终态也可由 loader 保留;不能从 provider 弱索引推断其状态。
|
||
let Value::Object(task) = session.candidate.vm.get_attr(loader, "task").unwrap() else {
|
||
panic!("Task");
|
||
};
|
||
// 查询实际 Task 类型方法,保持与源 task.status() 相同的受检查方法入口。
|
||
let definition = session
|
||
.candidate
|
||
.vm
|
||
.object_store()
|
||
.type_of(task)
|
||
.unwrap()
|
||
.unwrap();
|
||
// lookup_method 取得未绑定方法,所以下面 call 显式补一个 Task receiver。
|
||
let method = session
|
||
.candidate
|
||
.vm
|
||
.object_store()
|
||
.lookup_method(definition, "status")
|
||
.unwrap()
|
||
.unwrap();
|
||
// 返回值必须是语言字符串,状态接口若变成 Nil/对象不能被测试静默格式化成看似合法状态。
|
||
let Value::Text(status) = session
|
||
.candidate
|
||
.vm
|
||
.call(method, &[Value::Object(task)])
|
||
.unwrap()
|
||
else {
|
||
panic!("status");
|
||
};
|
||
status
|
||
}
|
||
/// 最多轮询 1000 次,每轮推进任务并检查其是否离开 pending。
|
||
/// 短暂休眠给后台线程执行机会;只要状态不是 pending 就返回,完成/取消/失败的具体期望由调用测试检查。
|
||
/// 1000 是轮数上限,每轮 poll 的耗时另计;这里没有承诺总墙钟时间不超过一秒。
|
||
fn complete_file_task(session: &mut ReplSession) {
|
||
// 测试调用方依赖此有界等待推进后台文件结果;达到上限必须失败,不能无限挂住测试进程。
|
||
for _ in 0..1000 {
|
||
// 后台只填 mailbox,必须显式让 owning VM poll 才能执行回调或完成无回调 Task。
|
||
session.poll_tasks().unwrap();
|
||
// 按非 pending 条件交还调用方,不是通用的 Task 终态判定(running 也满足此条件)。
|
||
if file_task_state(session) != "pending" {
|
||
// 当前文件测试随后显式断言 completed/cancelled/failed,不能仅凭本助手返回认定成功。
|
||
return;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||
}
|
||
panic!("file task did not complete");
|
||
}
|
||
#[test]
|
||
/// 同一 owner/key 连续发起两个不同文件读取,第二个必须取消旧任务。
|
||
/// 推进完成后仅新 UTF-8 内容写入 target;target 仍无 Mount,说明业务更新不依赖可见性。
|
||
fn file_task_updates_hidden_object_and_replacement_drops_old_delivery() {
|
||
with_file_task_input("old", |old| {
|
||
// 两个真实文件内容不同,才可从 target.text 判断究竟哪一代请求获得交付。
|
||
with_file_task_input("new UTF-8 文本", |new| {
|
||
// 路径先转义再作为源字符串,中文文本用于覆盖 UTF-8 返回而非仅 ASCII 情形。
|
||
let path = serde_json::to_string(&new.to_string_lossy()).unwrap();
|
||
// 保留旧 token 后同 owner/key 再读新文件,替换必须即时取消旧项而不靠旧结果晚到顺序碰巧正确。
|
||
let tail = format!(
|
||
"old = loader.task\nloader.task = ui.readText(loader,\"read\",{path},loader.apply)"
|
||
);
|
||
// 源加载只创建任务,complete_file_task 才在后续轮询交付,target 从未被挂到页面。
|
||
let (mut session, _) =
|
||
ReplSession::load(&file_task_source(old, &tail), "app.main", 282, 1).unwrap();
|
||
complete_file_task(&mut session);
|
||
assert_eq!(file_task_state(&mut session), "completed");
|
||
assert_eq!(
|
||
session.execute("target.text", "app.main").unwrap().value,
|
||
"\"new UTF-8 文本\""
|
||
);
|
||
assert_eq!(
|
||
session.execute("old.status()", "app.main").unwrap().value,
|
||
"\"cancelled\""
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("target.mounts().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
})
|
||
});
|
||
}
|
||
#[test]
|
||
/// 分别覆盖显式取消和 provider 读取不存在文件失败。
|
||
/// 二者都不能运行成功回调;后者需保留错误文本但不使共享 VM fault,目标文字仍为 waiting。
|
||
fn cancelled_file_task_never_runs_callback_and_missing_file_keeps_vm_live() {
|
||
with_file_task_input("ignored", |path| {
|
||
// 在同一输入创建并 cancel,后台即使已经读完也不能越过失效租约执行成功回调。
|
||
let (mut cancelled, _) = ReplSession::load(
|
||
&file_task_source(path, "loader.task.cancel()"),
|
||
"app.main",
|
||
283,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
complete_file_task(&mut cancelled);
|
||
assert_eq!(file_task_state(&mut cancelled), "cancelled");
|
||
assert_eq!(
|
||
cancelled.execute("target.text", "app.main").unwrap().value,
|
||
"\"waiting\""
|
||
);
|
||
// 另一个未创建的后缀路径用于 provider 真实失败,不伪造任务 error 字段。
|
||
let missing = path.with_extension("missing");
|
||
// 新独立会话区分 provider 错误与前一例主动取消状态,错误不应停整个 VM。
|
||
let (mut failed, _) =
|
||
ReplSession::load(&file_task_source(&missing, ""), "app.main", 284, 1).unwrap();
|
||
complete_file_task(&mut failed);
|
||
assert_eq!(file_task_state(&mut failed), "failed");
|
||
assert!(!failed.is_faulted());
|
||
assert_ne!(
|
||
failed
|
||
.execute("loader.task.error()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"\"\""
|
||
);
|
||
assert_eq!(
|
||
failed.execute("target.text", "app.main").unwrap().value,
|
||
"\"waiting\""
|
||
);
|
||
});
|
||
}
|
||
#[test]
|
||
/// 创建不带回调的 readText,完成后直接读取任务结果。
|
||
/// 确认 result 的托管保留不依赖成功回调,且 error 为空字符串。
|
||
fn callback_free_file_task_retains_result_after_completion() {
|
||
with_file_task_input("callback free", |path| {
|
||
// 同一文件接口省略 callback,仅靠保留的 Task.result 接收文字,检查 await 风格 API 的存值语义。
|
||
let path = serde_json::to_string(&path.to_string_lossy()).unwrap();
|
||
// Holder 只保存 token,页面展示无关文本;成功结果不能依赖 target 可见或回调消费才存活。
|
||
let source = format!(
|
||
"class Holder {{ task: Object }}\nloader = Holder(ui.state(0))\npage = Page()\npage.render(TextBlock(\"file\"))\nloader.task = ui.readText(ui,\"read\",{path})"
|
||
);
|
||
// 实际 VM 管理 Task 的拥有图,后台线程从不直接调用 Loader 或写源字段。
|
||
let (mut session, _) = ReplSession::load(&source, "app.main", 285, 1).unwrap();
|
||
complete_file_task(&mut session);
|
||
assert_eq!(file_task_state(&mut session), "completed");
|
||
assert_eq!(
|
||
session
|
||
.execute("loader.task.result()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"\"callback free\""
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("loader.task.error()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"\"\""
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
/// 以隐藏 Block 为 owner,用相同 key 替换定时任务。
|
||
/// 旧任务立即取消,新回调仅在 poll 后写入 2;另一个被显式取消的任务不能再改变状态。
|
||
fn owned_tasks_replace_cancel_and_update_unmounted_objects() {
|
||
// hidden Block 没有 Mount 仍可作业务 owner,两个动作故意写不同数值以观察同键替换胜出者。
|
||
let source = r#"class Action {
|
||
state: Object
|
||
value: Number
|
||
function apply(self: Action): Unit { self.state.value = self.value }
|
||
}
|
||
state = ui.state(0)
|
||
hidden = TextBlock("not mounted")
|
||
first = Action(state, 1)
|
||
latest = Action(state, 2)
|
||
page = Page()
|
||
page.render(ui.textOf(state,"value"))
|
||
old = ui.after(hidden,"load",0,first.apply)
|
||
next = ui.after(hidden,"load",0,latest.apply)"#;
|
||
// 创建任务本身不交付,先看旧 status 和初始 0,再 poll 验证只有最新动作写 2。
|
||
let (mut session, _) = ReplSession::load(source, "app.main", 1, 190).unwrap();
|
||
assert_eq!(
|
||
session.execute("old.status()", "app.main").unwrap().value,
|
||
"\"cancelled\""
|
||
);
|
||
assert_eq!(
|
||
session.execute("state.value", "app.main").unwrap().value,
|
||
"0"
|
||
);
|
||
assert!(session.poll_tasks().unwrap().is_some());
|
||
assert_eq!(
|
||
session.execute("state.value", "app.main").unwrap().value,
|
||
"2"
|
||
);
|
||
assert_eq!(
|
||
session.execute("next.status()", "app.main").unwrap().value,
|
||
"\"completed\""
|
||
);
|
||
session
|
||
.execute(
|
||
"cancelled=ui.after(ui,\"cancel\",0,first.apply)\ncancelled.cancel()",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
session.poll_tasks().unwrap();
|
||
// 被主动 cancel 的第三任务不能覆盖此前完成结果,token.active 也必须投影为 false。
|
||
assert_eq!(
|
||
session.execute("state.value", "app.main").unwrap().value,
|
||
"2"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("cancelled.active()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 并列创建 Page/Mount/Block/ui 作用域任务,再关闭并重开页面。
|
||
/// 页面与出现位置任务取消,业务对象和应用任务继续;跨窗迁移保留 Page 作用域但取消旧 Mount,关窗取消窗口任务。
|
||
fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() {
|
||
// 相同 Action/共享 count 给不同 owner 类型建任务,计数差异直接揭示哪些作用域还获得交付资格。
|
||
let source = r#"class Action {
|
||
state: Object
|
||
function apply(self: Action): Unit { self.state.increment() }
|
||
}
|
||
state=ui.state(0)
|
||
action=Action(state)
|
||
leaf=TextBlock("owner")
|
||
page=Page()
|
||
page.render(leaf)"#;
|
||
// 先发布 leaf 的真实出现,再从源 mounts() 获取具体 owner,不能手写一个未挂载 Mount。
|
||
let (mut session, _) = ReplSession::load(source, "app.main", 1, 191).unwrap();
|
||
session
|
||
.execute(
|
||
r#"held=leaf.mounts().at(0)
|
||
pageTask=ui.after(page,"page",0,action.apply)
|
||
mountTask=ui.after(held,"mount",0,action.apply)
|
||
blockTask=ui.after(leaf,"block",0,action.apply)
|
||
appTask=ui.after(ui,"app",0,action.apply)
|
||
page.close()
|
||
page.open()"#,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 两种展示 owner 都在关闭时失去资格,按各自 token 分别核对,避免只取消其中一种。
|
||
for name in ["pageTask", "mountTask"] {
|
||
// close 后立刻 open 不应复活旧资格;页面与旧出现的任务都已在关闭边界被取消。
|
||
assert_eq!(
|
||
session
|
||
.execute(&format!("{name}.status()"), "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"\"cancelled\""
|
||
);
|
||
}
|
||
session.poll_tasks().unwrap();
|
||
// 只有 Block/ui 两个 owner 仍有效,count=2 同时排除错误地取消所有后台工作或全部继续的实现。
|
||
assert_eq!(
|
||
session.execute("state.value", "app.main").unwrap().value,
|
||
"2"
|
||
);
|
||
// 跨窗移动保留 Page 自身作用域;原出现位置会被替换,因此旧 Mount 的任务必须取消。
|
||
session
|
||
.execute(
|
||
r#"moving=ui.after(page,"move",0,action.apply)
|
||
oldMount=leaf.mounts().at(0)
|
||
oldMountTask=ui.after(oldMount,"move",0,action.apply)
|
||
other=page.detachToWindow()"#,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("oldMountTask.status()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"\"cancelled\""
|
||
);
|
||
session.poll_tasks().unwrap();
|
||
assert_eq!(
|
||
session.execute("state.value", "app.main").unwrap().value,
|
||
"3"
|
||
);
|
||
session
|
||
.execute(
|
||
"closing=ui.after(other,\"close\",0,action.apply)\nother.close()",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 窗口 owner 关闭即失效,即使源变量 other 还保留真实窗口对象也不能继续回调。
|
||
session.poll_tasks().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("closing.status()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"\"cancelled\""
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 只返回任务令牌而不保留临时 owner,检查弱作用域允许 owner 回收并取消任务。
|
||
/// 随后第一个回调失败应停止共享执行域,后续递增不发生;通过 VM 直接读值验证状态仍为 0。
|
||
fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() {
|
||
// orphan 只返回 Task,owner 无外部根;后半段先排失败回调再排递增,检查共享故障域是否停止后续工作。
|
||
let source = r#"class Action {
|
||
state: Object
|
||
function apply(self: Action): Unit { self.state.increment() }
|
||
function fail(self: Action): Unit { ui.window().resize(0,600) }
|
||
}
|
||
function orphan(): Task {
|
||
let owner=TextBlock("temporary")
|
||
return ui.after(owner,"temporary",0,ui.window().focus)
|
||
}
|
||
state=ui.state(0)
|
||
action=Action(state)
|
||
page=Page()
|
||
page.render(TextBlock("preserve"))
|
||
token=orphan()"#;
|
||
// 加载后的回收边界应剪掉不可达 owner/作用域环,token 本身不反向强持有 owner。
|
||
// orphan 回调绑定主窗口而不是临时 owner,避免回调自身又强持有 owner,使本例无法观察其回收。
|
||
let (mut session, _) = ReplSession::load(source, "app.main", 1, 192).unwrap();
|
||
assert_eq!(
|
||
session.execute("token.active()", "app.main").unwrap().value,
|
||
"false"
|
||
);
|
||
session.poll_tasks().unwrap();
|
||
assert_eq!(
|
||
session.execute("token.status()", "app.main").unwrap().value,
|
||
"\"cancelled\""
|
||
);
|
||
session.execute("broken=ui.after(ui,\"fail\",0,action.fail)\nlater=ui.after(ui,\"later\",0,action.apply)","app.main").unwrap();
|
||
assert!(session.poll_tasks().is_err());
|
||
assert!(session.is_faulted());
|
||
// 故障域已拒绝 execute,直接只读 VM 属性验证值仍为 0,而不为了观察又执行用户代码。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 原共享状态对象仍由 globals 持有;读取它不创建新根或恢复后续被停用的任务。
|
||
let state = match session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("state".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
{
|
||
Value::Object(state) => state,
|
||
_ => panic!("state object"),
|
||
};
|
||
assert_eq!(
|
||
session.candidate.vm.get_attr(state, "value").unwrap(),
|
||
Value::Number(0.into())
|
||
);
|
||
assert!(session.poll_tasks().unwrap().is_none());
|
||
assert!(session.execute("42", "app.main").is_err());
|
||
}
|
||
|
||
#[test]
|
||
/// 验证源函数、原生函数和绑定方法属于 Function,而类型构造器不属于 Function。
|
||
/// 错误参数在函数体副作用前拒绝,错误返回在返回契约检查时拒绝;重定义同名类也不能改变旧实例/方法身份。
|
||
fn source_annotations_classify_functions_and_reject_before_body() {
|
||
// 示例同时含源函数、宿主工厂和绑定方法,类型对象虽可构造但不是 Function,需分别测试。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/function-types.endor"),
|
||
"app.main",
|
||
1,
|
||
180,
|
||
)
|
||
.unwrap();
|
||
// 分别查询源函数、原生工厂和绑定方法,三者都应进入 Function 名义类型体系。
|
||
for expression in [
|
||
"isInstance(identity,Function)",
|
||
"isInstance(ui.rect,Function)",
|
||
"isInstance(actions.read,Function)",
|
||
] {
|
||
// 三种真实 callable 都应满足同一个 Function 名义契约,不用是否有某个同名属性做鸭子判断。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
"true"
|
||
);
|
||
}
|
||
assert_eq!(
|
||
session
|
||
.execute("isInstance(RectBlock,Function)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"false"
|
||
);
|
||
session
|
||
.execute(
|
||
"function checked(value: Number): Number { state.increment() return value }",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 参数错误必须在进入 checked 的 increment 之前拒绝,因此紧接着断言 state 仍为 0。
|
||
let error = session
|
||
.execute("checked(\"wrong\")", "app.main")
|
||
.err()
|
||
.unwrap();
|
||
assert!(
|
||
error.to_string().contains("parameter value expects Number"),
|
||
"{error:#}"
|
||
);
|
||
assert_eq!(
|
||
session.execute("state.value", "app.main").unwrap().value,
|
||
"0"
|
||
);
|
||
assert_eq!(
|
||
session.execute("checked(7)", "app.main").unwrap().value,
|
||
"7"
|
||
);
|
||
session
|
||
.execute("function wrong(): Number { return \"wrong\" }", "app.main")
|
||
.unwrap();
|
||
// 错误返回发生在函数体正常跑完之后的返回契约检查,消息应和参数错误有所区分。
|
||
let error = session.execute("wrong()", "app.main").err().unwrap();
|
||
assert!(
|
||
error.to_string().contains("return expects Number"),
|
||
"{error:#}"
|
||
);
|
||
assert!(
|
||
session
|
||
.execute("callNumber(RectBlock)", "app.main")
|
||
.is_err()
|
||
);
|
||
session
|
||
.execute(
|
||
"class Actions { function read(self: Actions): Number { return 9 } }",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 重定义名字只建立新 Type,不改变旧实例 actions 与此前 savedFunction 的真实身份/行为。
|
||
assert_eq!(
|
||
session
|
||
.execute("callNumber(savedFunction)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"1"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("isInstance(actions,Actions)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"false"
|
||
);
|
||
session.execute("button.click()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session.execute("state.value", "app.main").unwrap().value,
|
||
"2"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 先由子窗口按钮回调制造运行时故障,再从宿主回收该窗口。
|
||
/// Mount 和页面根必须脱离,onUnmount 的 marker 不可递增;已停止 VM 不能因清理重新执行源代码。
|
||
fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() {
|
||
// marker 只在 onUnmount 增长,子窗 click 另触发非法 resize 故障,用两条独立状态观察纯宿主退役。
|
||
let source = r#"class WatchedPage: Page {
|
||
marker: Object
|
||
function onUnmount(self: WatchedPage, mount: Mount): Unit { self.marker.increment() }
|
||
}
|
||
class Failure {
|
||
function run(self: Failure, event: Event): Unit { ui.window().resize(0,600) }
|
||
}
|
||
marker=ui.state(0)
|
||
mainPage=Page()
|
||
mainPage.render(TextBlock("main"))
|
||
other=Window()
|
||
button=Button("fail")
|
||
failure=Failure()
|
||
connection=button.on("click",failure.run)
|
||
page=WatchedPage(marker)
|
||
page.render(button)
|
||
page.open(other)"#;
|
||
// 保存副窗发布帧和实际持久堆,故障后不重新加载一份“已经清理好”的模拟域。
|
||
let (mut session, initial) = ReplSession::load(source, "app.main", 1, 140).unwrap();
|
||
session
|
||
.execute("held=button.mounts().at(0)", "app.main")
|
||
.unwrap();
|
||
// 故障后只能只读观察真实对象,不能经 execute 调用户函数来检查清理结果。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// lookup 仅从原 globals 借出对象身份,持有关系没有被测试添加新的长期根改变。
|
||
let lookup = |session: &ReplSession, name: &str| match session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text(name.into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
{
|
||
Value::Object(object) => object,
|
||
_ => panic!("object"),
|
||
};
|
||
// held 故意保留旧叶 Mount,退役必须使它 mounted=false,而不能仅把它从窗口显示列表删去。
|
||
let held = lookup(&session, "held");
|
||
// 真实窗口资源用于检查 Page 根是否解除拥有关系,和文档副本是否删窗分别验证。
|
||
let window = lookup(&session, "other");
|
||
// marker 保持零证明故障清理没有重新进入 onUnmount 源代码。
|
||
let marker = lookup(&session, "marker");
|
||
// 用目标 payload 的实际几何 ID 构造输入,当前 session/副窗版本来自初始发布值。
|
||
let mount = session
|
||
.candidate
|
||
.vm
|
||
.payload::<crate::mount::Mount>(held, "Mount")
|
||
.unwrap()
|
||
.geometry
|
||
.id;
|
||
assert!(
|
||
session
|
||
.dispatch(&crate::events::Input {
|
||
window: 2,
|
||
session: 140,
|
||
layout_version: initial.windows[&2].frame.layout_version,
|
||
mount,
|
||
kind: "click".into(),
|
||
point: None,
|
||
delta: None,
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.is_err()
|
||
);
|
||
assert!(session.is_faulted());
|
||
// 专用 retire_window 不调用源 Window.close/卸载钩子;正常生命周期与故障资源清理不能混为一路。
|
||
session.retire_window(2).unwrap();
|
||
assert!(!session.accepts_window(2));
|
||
assert!(
|
||
!session
|
||
.candidate
|
||
.vm
|
||
.payload::<crate::mount::Mount>(held, "Mount")
|
||
.unwrap()
|
||
.mounted
|
||
);
|
||
assert!(
|
||
crate::window::pages(session.candidate.vm.object_store(), window)
|
||
.unwrap()
|
||
.is_empty()
|
||
);
|
||
assert_eq!(
|
||
session.candidate.vm.get_attr(marker, "value").unwrap(),
|
||
Value::Number(0.into())
|
||
);
|
||
assert!(session.execute("42", "app.main").is_err());
|
||
}
|
||
|
||
#[test]
|
||
/// 加载多窗口示例,确认窗口共享业务计数但使用不重叠的 Mount。
|
||
/// 迁移保留 Page 身份、替换旧 Mount;关闭并清除全局引用后 Window 可回收,保留旧叶 Mount 也不能留住窗口上下文。
|
||
fn native_windows_share_business_state_move_pages_and_release_closed_contexts() {
|
||
// 真实多窗口示例在同一个 VM 共享 count,每窗只派生独立 Presentation/Mount,而非复制业务模型。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/windows.endor"),
|
||
"app.main",
|
||
1,
|
||
130,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(initial.windows.len(), 1);
|
||
// 全会话出现 ID 高水位应避免不同窗重号,防止输入凭窗口/出现值误指到另一展示。
|
||
let main_ids: std::collections::BTreeSet<_> =
|
||
initial.frame.mounts.iter().map(|mount| mount.id).collect();
|
||
assert!(
|
||
initial.windows[&2]
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.all(|mount| !main_ids.contains(&mount.id))
|
||
);
|
||
assert_eq!(initial.windows[&2].frame.session, initial.frame.session);
|
||
// 同一共享 count 更新应在两个窗的完整文档里同时看到新文字,而不是只刷新 current。
|
||
let changed = session
|
||
.execute("count.increment()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 仅过滤实际文字命令判断状态投影,不从局部 ui.stats 或源 state 对象推断画面已同步。
|
||
let has_one = |frame: &crate::document::Frame| {
|
||
frame
|
||
.commands
|
||
.iter()
|
||
.any(|command| matches!(command,RenderCommand::Text{value,..} if value=="1"))
|
||
};
|
||
assert!(has_one(&changed.frame) && has_one(&changed.windows[&2].frame));
|
||
session
|
||
.execute("oldRight = rightButton.mounts().at(0)", "app.main")
|
||
.unwrap();
|
||
// 记录 Page 的 Console 对象身份表示,迁移后必须仍为同一个业务页。
|
||
let page = session.execute("right", "app.main").unwrap().value;
|
||
// 正常源方法迁移到主窗,旧副窗 Page 表清空,新 Mount 应在新上下文重建。
|
||
let moved = session
|
||
.execute("right.moveToWindow(ui.window())", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(moved.windows[&2].frame.tabs.is_empty());
|
||
assert_eq!(session.execute("right", "app.main").unwrap().value, page);
|
||
assert_eq!(
|
||
session
|
||
.execute("right.window().id()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"1"
|
||
);
|
||
assert!(session.execute("oldRight.bounds()", "app.main").is_err());
|
||
// 从真实全局取得副窗身份,创建弱观察句柄后删除原全局强边以检验资源可回收性。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 读取不额外 retain Window,避免测试自己保活对象而掩盖/制造 GC 结论。
|
||
let Value::Object(window) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("other".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Window");
|
||
};
|
||
// 弱身份只用于回收后探测,不能因为闭窗时仍有 Rust ObjectRef 数值就延长寿命。
|
||
let weak = session.candidate.vm.downgrade(window).unwrap();
|
||
// 关闭后的最终完整文档应删除副窗,向已关闭资源 open 也必须被拒绝。
|
||
let closed = session
|
||
.execute("other.close()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(closed.windows.is_empty());
|
||
assert!(session.execute("right.open(other)", "app.main").is_err());
|
||
session
|
||
.candidate
|
||
.vm
|
||
.dict_delete(globals, &Value::Text("other".into()))
|
||
.unwrap();
|
||
session.execute("0", "app.main").unwrap();
|
||
session.candidate.vm.collect_cycles();
|
||
// oldRight 仍可达不应通过出现的旧上下文反向保留整个 Window/desktop/Page 注册表。
|
||
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
|
||
// 外部仍持有旧叶 Mount 时,关闭窗口的上下文也必须可释放;下一次 detach 创建新的资源编号。
|
||
let detached = session
|
||
.execute("detached = right.detachToWindow()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(detached.windows.contains_key(&3));
|
||
session.execute("detached.close()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session.execute("right.window()", "app.main").unwrap().value,
|
||
"nil"
|
||
);
|
||
assert_eq!(
|
||
session.execute("count.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
session
|
||
.execute("right.open(ui.window())", "app.main")
|
||
.unwrap();
|
||
assert_eq!(session.execute("right", "app.main").unwrap().value, page);
|
||
}
|
||
|
||
#[test]
|
||
/// 一次输入先关闭子窗口,再用非法 resize 使后续语句失败。
|
||
/// 先前关闭副作用仍存在,下一次成功输入必须清除残留展示;Page 可重新打开到主窗口。
|
||
fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() {
|
||
// 从真实多窗堆开始,输入前一条 close 成功、后一条 resize 失败,验证 REPL 非事务语义的恢复。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/windows.endor"),
|
||
"app.main",
|
||
1,
|
||
131,
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
session
|
||
.execute("other.close()\nui.window().resize(0,600)", "app.main")
|
||
.is_err()
|
||
);
|
||
assert!(!session.accepts_window(2));
|
||
// 下一次成功查询触发必要修复/发布,关闭副作用不回滚,旧窗也不能在缓存文档中残留。
|
||
let repaired = session.execute("right.window()", "app.main").unwrap();
|
||
assert_eq!(repaired.value, "nil");
|
||
assert!(repaired.document.unwrap().windows.is_empty());
|
||
assert_eq!(
|
||
session
|
||
.execute("right.mounts().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
session
|
||
.execute("right.open(ui.window())", "app.main")
|
||
.unwrap();
|
||
// 已清理 Page 仍可按业务身份重开到主窗口,不能把正常关闭误当整个 Page 值被永久销毁。
|
||
assert_eq!(
|
||
session
|
||
.execute("right.window().id()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"1"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 依次通过事件激活页面、修改 title、关闭页面、清空及重开。
|
||
/// 检查标签反映真实 Page 方法:同窗存续标签保持数字 ID,关闭并发布后重开会获新 ID;旧版本事件被桥接层拒绝。
|
||
fn tab_projection_uses_page_methods_and_rejects_old_presentations() {
|
||
// 页面标题/打开顺序来自真实 Window 注册表,最后一次 render 使 second 初始选中。
|
||
let source = r#"first = Page()
|
||
first.title = "First"
|
||
first.render(TextBlock("one"))
|
||
second = Page()
|
||
second.title = "Second"
|
||
second.render(TextBlock("two"))"#;
|
||
// 保存初始 Tab ID 和版本供后续重命名/重开/过期输入比较,不按标题作为身份。
|
||
let (mut session, initial) = ReplSession::load(source, "app.main", 120, 1).unwrap();
|
||
assert_eq!(initial.frame.tabs.len(), 2);
|
||
assert_eq!(initial.frame.tabs[0].title, "First");
|
||
assert!(initial.frame.tabs[1].active);
|
||
// 标签分派用 mount 字段承载 Tab ID,kind 明确为 tab-activate,不能误入内容几何命中。
|
||
let input = crate::events::Input {
|
||
window: crate::window::MAIN_WINDOW,
|
||
session: initial.frame.session,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: initial.frame.tabs[0].id,
|
||
kind: "tab-activate".into(),
|
||
point: None,
|
||
delta: None,
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
};
|
||
// 真实页面 activate 切换选中投影并推进版本,随后同一输入应由桥接层拒绝。
|
||
let selected = session.dispatch(&input).unwrap().document.unwrap();
|
||
assert!(selected.frame.tabs[0].active);
|
||
assert!(selected.frame.layout_version > initial.frame.layout_version);
|
||
// 直接检查桥接层的版本拒绝:过期传输在进入源回调之前失败,不应被当成运行时故障。
|
||
assert!(
|
||
session
|
||
.bridge
|
||
.dispatch(&mut session.candidate.vm, &input)
|
||
.is_err()
|
||
);
|
||
// 只改 second.title 后检查第二页文字更新,并核对未改动的第一页 ID;此处没有断言第二页 ID 本身不变。
|
||
let renamed = session
|
||
.execute(r#"second.title = "Renamed""#, "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert_eq!(renamed.frame.tabs[1].title, "Renamed");
|
||
assert_eq!(renamed.frame.tabs[0].id, initial.frame.tabs[0].id);
|
||
// 按最新版本关闭 second,注册关系和焦点选择应同步到第一页,不使用静态 sidebar 假数据。
|
||
let closed = session
|
||
.dispatch(&crate::events::Input {
|
||
window: crate::window::MAIN_WINDOW,
|
||
session: renamed.frame.session,
|
||
layout_version: renamed.frame.layout_version,
|
||
mount: renamed.frame.tabs[1].id,
|
||
kind: "tab-close".into(),
|
||
point: None,
|
||
delta: None,
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert_eq!(closed.frame.tabs.len(), 1);
|
||
assert!(closed.frame.tabs[0].active);
|
||
assert_eq!(
|
||
session
|
||
.execute("second.window()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"nil"
|
||
);
|
||
// 最后一页关闭也必须发布版本更高的空标签帧,不能把 Some(None) 当作无刷新工作。
|
||
let empty = session
|
||
.execute("first.close()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(empty.frame.tabs.is_empty());
|
||
assert!(empty.frame.layout_version > closed.frame.layout_version);
|
||
// 相同 Page 重开分配新展示 Tab ID,历史旧输入不能因业务对象相同再次变成有效。
|
||
let reopened = session
|
||
.execute("second.open()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert_eq!(reopened.frame.tabs.len(), 1);
|
||
assert!(reopened.frame.tabs[0].id > initial.frame.tabs[1].id);
|
||
}
|
||
|
||
#[test]
|
||
/// 同次成功输入的两次 resize 合并为最后尺寸;检查命令共三条及前两条的 resize/title 内容。
|
||
/// 本例提交了 focus,但没有单独断言第三条动作类型;不能将数量检查当成完整命令序列校验。
|
||
/// 命令只消费一次;失败输入之前积累的 resize 也必须丢弃,不能送到原生窗口。
|
||
fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_requests() {
|
||
// 空持久会话已有托管主 Window,命令登记不需要先 render 页面,也不在测试实际打开 OS 窗口。
|
||
let mut session = ReplSession::new(111).unwrap();
|
||
session
|
||
.execute(
|
||
r#"window = ui.window()
|
||
window.resize(900,600)
|
||
window.resize(1000,700)
|
||
window.setTitle("Endor")
|
||
window.focus()"#,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 一次取完成功输入命令,两个 Resize 合并为最后一个,title/focus 保留独立动作顺序。
|
||
let requests = session.take_window_commands().unwrap();
|
||
assert_eq!(requests.len(), 3);
|
||
assert!(matches!(
|
||
requests[0].action,
|
||
crate::window::Action::Resize {
|
||
width: 1000.0,
|
||
height: 700.0
|
||
}
|
||
));
|
||
assert!(matches!(&requests[1].action,crate::window::Action::SetTitle(title) if title=="Endor"));
|
||
assert!(session.take_window_commands().unwrap().is_empty());
|
||
// 输入先排合法 resize 再遇非法尺寸,待发布 outbox 整批丢弃,不能泄漏前半条原生命令。
|
||
assert!(
|
||
session
|
||
.execute("window.resize(1400,900)\nwindow.resize(0,600)", "app.main")
|
||
.is_err()
|
||
);
|
||
assert!(session.take_window_commands().unwrap().is_empty());
|
||
assert!(session.execute("window.resize(0,600)", "app.main").is_err());
|
||
assert!(session.take_window_commands().unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
/// 检查 Window.pages 真实持有打开页面,close 解除归属,重新 open 追加到当前顺序末尾。
|
||
/// 页面窗口关系保持双向一致,向非 Window 对象打开则明确失败。
|
||
fn window_page_roots_follow_open_close_and_creation_order() {
|
||
// 创建顺序与 render/open 登记关系明确分开,Page 存活不代表永远属于窗口页面表。
|
||
let source = r#"first = Page()
|
||
second = Page()
|
||
first.render(TextBlock("first"))
|
||
second.render(TextBlock("second"))
|
||
window = ui.window()"#;
|
||
// 所有查询保留同一真实 globals,close/open 后的窗口关系必须从资源模型更新而来。
|
||
let (mut session, _) = ReplSession::load(source, "app.main", 112, 1).unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("window.pages().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
// 两页应指同一 Window;保存显示身份进行比较,不用标题或数组顺序猜归属。
|
||
let first = session.execute("first.window()", "app.main").unwrap().value;
|
||
assert_eq!(
|
||
first,
|
||
session
|
||
.execute("second.window()", "app.main")
|
||
.unwrap()
|
||
.value
|
||
);
|
||
session.execute("first.close()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session.execute("first.window()", "app.main").unwrap().value,
|
||
"nil"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("window.pages().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"1"
|
||
);
|
||
session.execute("first.open(window)", "app.main").unwrap();
|
||
// 重开追加到注册表末尾,second 因此仍在 at(0),而不是按最初创建顺序强行插回 first。
|
||
assert_eq!(
|
||
session
|
||
.execute("window.pages().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("window.pages().at(0)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
session.execute("second", "app.main").unwrap().value
|
||
);
|
||
assert!(session.execute("first.open(Object())", "app.main").is_err());
|
||
}
|
||
|
||
#[test]
|
||
/// 为 Page 建立弱句柄,close 后删除唯一全局绑定并运行循环回收。
|
||
/// 弱升级应失败,证明窗口的页面顺序记录不会把已关闭页面意外作为强根保留。
|
||
fn closed_page_is_not_kept_alive_by_window_page_order() {
|
||
// 简单页面保证唯一显式根就是全局 page;关闭+删绑定后窗口的顺序映射不得成为隐藏强根。
|
||
let (mut session, _) = ReplSession::load(
|
||
"page = Page()\npage.render(TextBlock(\"page\"))",
|
||
"app.main",
|
||
113,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 直接删 globals 避免用“page=nil”产生别名替换语义干扰这个生命周期断言。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 借出身份后只建弱观察,不在测试手工 retain 新拥有根。
|
||
let Value::Object(page) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("page".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Page")
|
||
};
|
||
// close 解除 Window→Page 边,删除全局再收环,弱升级失败才证明没有意外保留。
|
||
let weak = session.candidate.vm.downgrade(page).unwrap();
|
||
session.execute("page.close()", "app.main").unwrap();
|
||
session
|
||
.candidate
|
||
.vm
|
||
.dict_delete(globals, &Value::Text("page".into()))
|
||
.unwrap();
|
||
session.candidate.vm.collect_cycles();
|
||
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// 源 measure 内尝试 resize 原生窗口,加载应报告 read-only。
|
||
/// 窗口命令也属于副作用,不能因其延后执行而绕过布局只读保护。
|
||
fn rendering_cannot_enqueue_native_window_side_effects() {
|
||
// measure 返回值本身合法,唯一违规是排入延后 Resize,证明 outbox 也是受只读保护的副作用。
|
||
let source = r##"class BadRect: RectBlock {
|
||
function measure(self: BadRect, mount: Mount, constraints: Constraints): MeasureResult {
|
||
ui.window().resize(900,600)
|
||
return MeasureResult(80,20)
|
||
}
|
||
}
|
||
page = Page()
|
||
page.render(BadRect(80,20,0,"#DDEEFF"))"##;
|
||
// 真实加载会运行 source measure,在候选发布前发现错误,不能等 OS 执行命令时才拒绝。
|
||
let error = ReplSession::load(source, "app.main", 114, 1).err().unwrap();
|
||
assert!(error.to_string().contains("read-only"));
|
||
}
|
||
|
||
/// 直接调用 ui.stats 取得某个数字计数,读取后释放返回的 stats 托管对象。
|
||
/// 辅助函数避免经 REPL 查询统计带来额外输入/刷新,使缓存命中与测量次数的断言可比较。
|
||
fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 {
|
||
// 布局/缓存回归共用此直接 VM 助手,绕开 Console transcript 和一次输入引起的额外刷新。
|
||
let Value::Object(function) = session
|
||
.candidate
|
||
.vm
|
||
.get_attr(session.bridge.ui, "stats")
|
||
.unwrap()
|
||
else {
|
||
panic!("stats function")
|
||
};
|
||
// 返回 stats 对象有新的拥有根,读完数字字段必须显式释放,不能让观察指标自身泄漏。
|
||
let Value::Object(stats) = session.candidate.vm.call(function, &[]).unwrap() else {
|
||
panic!("stats")
|
||
};
|
||
// 要求确为 Number,不能将缺失字段/错误 API 值格式化成零而让缓存断言误通过。
|
||
let Value::Number(value) = session.candidate.vm.get_attr(stats, name).unwrap() else {
|
||
panic!("stat number")
|
||
};
|
||
session.candidate.vm.release(stats).unwrap();
|
||
value.as_i64().unwrap()
|
||
}
|
||
/// 从 globals 中按名称找到业务 Block,再在给定完整文档中按 block_id 找对应 Mount。
|
||
/// 构造携带当前会话与该文档版本的 click,返回派发后发布的新文档,供后续动作沿新版本继续。
|
||
fn click_named(session: &mut ReplSession, document: &RenderDocument, name: &str) -> RenderDocument {
|
||
// 缓存示例测试通过此助手模拟按钮点击;globals 是当前 VM 的业务对象名字表。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 按调用方给出的名字取得 Block 引用;缺失或非对象表示测试夹具已与按钮约定不符。
|
||
let Value::Object(block) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text(name.into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Block")
|
||
};
|
||
// 将业务 Block 对应到当前文档的出现位置;本助手适用于这些按钮只有一个 Mount 的夹具。
|
||
let mount = document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.block_id == block.to_string())
|
||
.unwrap();
|
||
// session 身份取当前会话,layout_version 取传入文档;调用方必须传该会话的最新完整主窗文档。
|
||
// 不提供坐标,直接验证指定按钮的 click 回调;要求返回新文档供下一次点击使用。
|
||
session
|
||
.dispatch(&crate::events::Input {
|
||
window: crate::window::MAIN_WINDOW,
|
||
session: session.id,
|
||
layout_version: document.frame.layout_version,
|
||
mount: mount.id,
|
||
kind: "click".into(),
|
||
point: None,
|
||
delta: None,
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap()
|
||
.document
|
||
.unwrap()
|
||
}
|
||
|
||
#[test]
|
||
/// 同一个源 paint 对象出现在两个窗口,先执行与显示无关的表达式。
|
||
/// 无关输入应不产生文档;修改 paint 实际读取的 tint 后,两个窗口都必须发布新颜色。
|
||
fn shared_intrinsic_desktop_does_not_repaint_other_windows_on_repl_input() {
|
||
// 同一 Painted 实例挂到两个窗口,paint 读取共享 tint,建立跨窗口的字段依赖。
|
||
let (mut session, _) = ReplSession::load(
|
||
r##"
|
||
class Painted: RectBlock {
|
||
tint: String
|
||
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
|
||
drawing = RectBlock.paint(self,mount,geometry)
|
||
drawing.commands.at(0).color = self.tint
|
||
return drawing
|
||
}
|
||
}
|
||
item = Painted(80,20,0,"#DDEEFF","#ED6A5A")
|
||
page = Page()
|
||
page.render(item)
|
||
window2 = Window()
|
||
page2 = Page()
|
||
page2.render(item)
|
||
page2.moveToWindow(window2)
|
||
"##,
|
||
"app.main",
|
||
901,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 执行不读写业务对象的表达式,检查 worker 不为无关 REPL 输入重新发布桌面。
|
||
assert!(
|
||
session
|
||
.execute("42", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_none()
|
||
);
|
||
// 修改 paint 实际读取的字段;返回的桌面文档应同时包含两处更新。
|
||
let changed = session
|
||
.execute("item.tint = \"#3868D9\"", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 主窗口的矩形命令必须采用新颜色,不能只检查有文档发布。
|
||
assert!(changed.frame.commands.iter().any(|command| matches!(
|
||
command, RenderCommand::RoundedRect { color, .. } if color == "#3868D9"
|
||
)));
|
||
// 再核对第二窗口,捕获只刷新当前窗口而遗漏共享对象另一出现位置的问题。
|
||
assert!(
|
||
changed.windows[&2]
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.any(|command| matches!(
|
||
command, RenderCommand::RoundedRect { color, .. } if color == "#3868D9"
|
||
))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 加载缓存示例并用真实 click 分别改变颜色与尺寸。
|
||
/// 纯颜色更新复用全部测量,尺寸依赖变化只让受影响的结果失效;按布局 pass 数核对命中而非写死单次计数。
|
||
fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes() {
|
||
// 以固定视口加载真实缓存示例;CachedRect 和 ViewportBand 分别提供可缓存的源测量。
|
||
let (mut session, initial) = ReplSession::load_with_viewport(
|
||
include_str!("../../../examples/layout-cache.endor"),
|
||
"app.main",
|
||
101,
|
||
1,
|
||
Some(crate::layout::Size {
|
||
width: 800.0,
|
||
height: 600.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 初始两项源测量均需执行;后面的统计由每次更新产生,检查更新本身的复用情况。
|
||
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 2);
|
||
// 调用上面的无坐标 click 助手触发按钮;覆盖运行时事件/回调路径,不覆盖原生点击坐标与源形状命中。
|
||
let painted = click_named(&mut session, &initial, "blue");
|
||
// 核对颜色已变化,排除因按钮未生效而导致的“没有重新测量”。
|
||
assert!(painted.frame.commands.iter().any(
|
||
|command| matches!(command,RenderCommand::RoundedRect {color,..} if color=="#526AC7")
|
||
));
|
||
// 同一次更新可能经历多个布局 pass;每个 pass 都应命中两项测量,实际重测数应为零。
|
||
let passes = source_measure_stat(&mut session, "layoutPasses");
|
||
assert_eq!(
|
||
source_measure_stat(&mut session, "sourceMeasureHits"),
|
||
2 * passes
|
||
);
|
||
assert_eq!(source_measure_stat(&mut session, "measured"), 0);
|
||
// 接着沿新文档点击尺寸按钮,改动 CachedRect.measure 读取的宽度模型。
|
||
let resized = click_named(&mut session, &painted, "expand");
|
||
// 检查依赖更新已反映为 260×60 的矩形几何。
|
||
assert!(
|
||
resized
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.any(|mount| mount.width == 260.0 && mount.height == 60.0)
|
||
);
|
||
// 此次只有一项测量失效一次;其余 pass/对象仍命中,因此总命中数为 2×pass−1。
|
||
let passes = source_measure_stat(&mut session, "layoutPasses");
|
||
assert_eq!(
|
||
source_measure_stat(&mut session, "sourceMeasureHits"),
|
||
2 * passes - 1
|
||
);
|
||
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1);
|
||
}
|
||
|
||
#[test]
|
||
/// 新建尚未 render 的 Console VM,执行普通表达式和 resize。
|
||
/// 两者都不应产生发布文档,避免把 reset 后保留的画面替换成空白。
|
||
fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() {
|
||
// 构造未执行 page.render 的新会话,用来复现 Console reset 后仍保留旧画面的情形。
|
||
let mut session = ReplSession::new_with_viewport(
|
||
104,
|
||
Some(crate::layout::Size {
|
||
width: 800.0,
|
||
height: 600.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 普通表达式没有新页面,返回值不能携带空白 RenderDocument。
|
||
assert!(
|
||
session
|
||
.execute("42", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_none()
|
||
);
|
||
// 原生 resize 更新视口状态,但没有页面可发布时仍应返回 None。
|
||
assert!(
|
||
session
|
||
.resize(Some(crate::layout::Size {
|
||
width: 360.0,
|
||
height: 240.0
|
||
}))
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
// 读取 VM 暴露的视口宽度,证明 resize 已生效而非被直接忽略;查询也不产生文档。
|
||
let result = session.execute("ui.viewport().width", "app.main").unwrap();
|
||
assert_eq!(result.value, "360");
|
||
assert!(result.document.is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// 源 measure 返回先前已创建的 MeasureResult,不在调用期间新建结果。
|
||
/// 直接改其 width 后刷新必须失效并更新相同 Mount,证明返回值解码读取也进入依赖;再次刷新保持稳定。
|
||
fn source_measure_cache_tracks_fields_of_an_existing_returned_result() {
|
||
// measure 直接返回全局已有结果;用此夹具验证解码结果字段时也记录依赖。
|
||
let source = r##"class HeldRect: RectBlock {
|
||
result: MeasureResult
|
||
function measure(self: HeldRect, mount: Mount, constraints: Constraints): MeasureResult { return self.result }
|
||
}
|
||
result = MeasureResult(80,20)
|
||
rect = HeldRect(80,20,0,"#DDEEFF",result)
|
||
page = Page()
|
||
page.render(rect)"##;
|
||
// 首次发布建立 width=80 的测量缓存,随后绕过 REPL 直接写堆字段。
|
||
let (mut session, initial) = ReplSession::load(source, "app.main", 103, 1).unwrap();
|
||
// 记住原出现位置 ID;宽度变更必须更新该 Mount,而不是新建另一个来掩盖缓存问题。
|
||
let id = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.width == 80.0)
|
||
.unwrap()
|
||
.id;
|
||
// 从业务全局表提取 result 对象,与 measure 返回的引用保持一致。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 要求夹具中的 result 确实是 VM 对象,便于通过 set_attr 触发写入观察。
|
||
let Value::Object(result) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("result".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("result")
|
||
};
|
||
// 仅改已有返回对象的 width;源函数本体未读取该字段,依赖应来自返回值解码。
|
||
session
|
||
.candidate
|
||
.vm
|
||
.set_attr(result, "width", Value::Number(140.into()))
|
||
.unwrap();
|
||
// 主动刷新消费字段变化,再检查同一 Mount 的尺寸及恰好一次缓存失效。
|
||
let next = session.refresh_document().unwrap().unwrap();
|
||
assert!(
|
||
next.frame
|
||
.mounts
|
||
.iter()
|
||
.any(|mount| mount.id == id && mount.width == 140.0)
|
||
);
|
||
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1);
|
||
// 再次刷新没有新写入,应保持缓存和已发布文档稳定。
|
||
assert!(session.refresh_document().unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// 在有限窗口约束下加载缓存示例,再缩窄真实可用视口。
|
||
/// 原 Mount ID 不变,内容宽度按根 padding 改变,且输入计数仍为 0;重复尺寸不发布,NaN 尺寸失败但不停止 VM。
|
||
fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_source_input() {
|
||
// 固定初始视口,让示例的 ViewportBand 建立对 ui.viewport().width 的测量依赖。
|
||
let (mut session, initial) = ReplSession::load_with_viewport(
|
||
include_str!("../../../examples/layout-cache.endor"),
|
||
"app.main",
|
||
102,
|
||
1,
|
||
Some(crate::layout::Size {
|
||
width: 800.0,
|
||
height: 600.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// viewport 已是实际页面可用区域,初始与 resize 后都只扣 Endor 根左右各 24px 的 padding。
|
||
assert!(
|
||
initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.any(|mount| mount.width == 752.0 && mount.height == 24.0)
|
||
);
|
||
// 保存完整 Mount 顺序和身份,后续检查 resize 只更新几何而不重建出现位置。
|
||
let ids: Vec<_> = initial.frame.mounts.iter().map(|mount| mount.id).collect();
|
||
// 模拟原生窗口缩小;该入口应更新布局约束,不执行一条新的 Endor 用户输入。
|
||
let document = session
|
||
.resize(Some(crate::layout::Size {
|
||
width: 360.0,
|
||
height: 240.0,
|
||
}))
|
||
.unwrap()
|
||
.unwrap();
|
||
// 逐项比较原 Mount ID,捕获 resize 导致生命周期重建或排序改变的问题。
|
||
assert_eq!(
|
||
ids,
|
||
document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.map(|mount| mount.id)
|
||
.collect::<Vec<_>>()
|
||
);
|
||
// 新宽度 360 扣除左右各 24 的 padding,应发布 312 的内容宽度。
|
||
assert!(
|
||
document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.any(|mount| mount.width == 312.0 && mount.height == 24.0)
|
||
);
|
||
// resize 不计作 REPL 输入;相同视口重复通知也不应再发布文档。
|
||
assert_eq!(session.inputs, 0);
|
||
assert!(
|
||
session
|
||
.resize(Some(crate::layout::Size {
|
||
width: 360.0,
|
||
height: 240.0
|
||
}))
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
// 非法 NaN 在视口边界被拒绝;这是参数错误,不应把仍可用的 VM 标成 faulted。
|
||
assert!(
|
||
session
|
||
.resize(Some(crate::layout::Size {
|
||
width: f32::NAN,
|
||
height: 240.0
|
||
}))
|
||
.is_err()
|
||
);
|
||
assert!(!session.is_faulted());
|
||
}
|
||
|
||
// 后续布局依赖、共享出现与非法协议测试共用的源夹具;replace 仅派生各测试输入,不修改示例文件。
|
||
const SOURCE_LAYOUT: &str = include_str!("../../../examples/source-layout.endor");
|
||
|
||
#[test]
|
||
/// 检查首次 source measure/arrange 已可读取结构关系与子测量结果,产出预期位置。
|
||
/// 直接改 Metrics.width 后通过依赖刷新同一 Mount,随后无变化刷新不再发布。
|
||
fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependencies() {
|
||
// 复用 source-layout 示例,经加载入口执行首次测量、排列和发布。
|
||
let (mut session, initial) = ReplSession::load(SOURCE_LAYOUT, "app.main", 91, 1).unwrap();
|
||
// 从已发布几何定位目标子矩形,检查父关系和子测量在首轮布局时已经可用。
|
||
let rect = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.width == 200.0 && mount.height == 60.0)
|
||
.unwrap();
|
||
assert_eq!((rect.x, rect.y), (44.0, 24.0));
|
||
// 保留出现位置身份,后续模型写入应只改变同一位置的宽度。
|
||
let id = rect.id;
|
||
// 文字的 y 坐标同时验证排列对子测量高度与间距的使用。
|
||
assert!(initial.frame.commands.iter().any(|command| matches!(command,RenderCommand::Text { value,y,.. } if value.starts_with("尺寸来自") && *y == 100.0)));
|
||
// 取出示例的 Metrics 模型,直接写入源测量读过的字段。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 夹具必须提供 Metrics 对象;类型不符应立即失败而非跳过依赖测试。
|
||
let Value::Object(metrics) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("metrics".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Metrics")
|
||
};
|
||
// 通过 VM 字段写入通知依赖系统;没有重新执行整段页面源码。
|
||
session
|
||
.candidate
|
||
.vm
|
||
.set_attr(metrics, "width", Value::Number(260.into()))
|
||
.unwrap();
|
||
// 刷新应重算相关几何并发布新文档。
|
||
let next = session.refresh_document().unwrap().unwrap();
|
||
// 按旧 ID 找回同一矩形,核对位置仍为 44、宽度更新为 260。
|
||
let rect = next
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.id == id)
|
||
.unwrap();
|
||
assert_eq!((rect.x, rect.width), (44.0, 260.0));
|
||
// 消费完变化后再次刷新应无输出,确认布局已收敛。
|
||
assert!(session.refresh_document().unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// 把同一 rect 放入具有不同 Metrics 的两个父容器。
|
||
/// 发布宽度必须分别为 200/140,不能把共享 Block 的一份测量结果误用于两个出现位置。
|
||
fn shared_block_source_measurements_belong_to_each_occurrence() {
|
||
// 保留原父容器,再创建另一份 Metrics;两个父容器共享同一 rect 业务对象。
|
||
let source = SOURCE_LAYOUT.replace(
|
||
"page.render(root)",
|
||
r#"otherMetrics = Metrics(140, 12)
|
||
other = OffsetColumn(24, 16, rect, otherMetrics)
|
||
page.render(Row(0, 0, root, other))"#,
|
||
);
|
||
// 在同一页面发布两个出现位置,使错误的按 Block 缓存策略可以被观察到。
|
||
let (mut session, initial) = ReplSession::load(&source, "app.main", 92, 1).unwrap();
|
||
// 从全局表取得共享 rect 的业务身份,避免按几何误匹配父容器。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 要求共享对象存在,后续用其 ID 同时筛选两个 Mount。
|
||
let Value::Object(rect) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("rect".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Rect")
|
||
};
|
||
// 保留文档出现顺序,收集同一 Block 在不同父约束/模型上下文中的测量宽度。
|
||
let widths: Vec<_> = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.filter(|mount| mount.block_id == rect.to_string())
|
||
.map(|mount| mount.width)
|
||
.collect();
|
||
// 两处分别采用各自 Metrics 的 200/140,后续空刷新确认没有持续失效。
|
||
assert_eq!(widths, vec![200.0, 140.0]);
|
||
assert!(session.refresh_document().unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// measure 读取宽度 80,onMount 随后把该依赖写成 140。
|
||
/// 首次发布必须已经重测为 140,不能把回调前的旧几何暴露出去,且后续刷新应稳定。
|
||
fn layout_dependencies_changed_by_mount_hook_are_measured_again_before_publication() {
|
||
// 首次 measure 读取 80,首次 onMount 再改为 140,构造发布前依赖失效的时序。
|
||
let source = r##"class Metrics { width: Number }
|
||
class InitialRect: RectBlock {
|
||
metrics: Metrics
|
||
function measure(self: InitialRect, mount: Mount, constraints: Constraints): MeasureResult {
|
||
return MeasureResult(self.metrics.width, 20)
|
||
}
|
||
function onMount(self: InitialRect, mount: Mount): Unit { self.metrics.width = 140 }
|
||
}
|
||
metrics = Metrics(80)
|
||
rect = InitialRect(80,20,0,"#DDEEFF",metrics)
|
||
page = Page()
|
||
page.render(rect)"##;
|
||
// load 完成生命周期与布局收敛后才返回文档,调用方不应见到中间状态。
|
||
let (mut session, document) = ReplSession::load(source, "app.main", 93, 1).unwrap();
|
||
// 检查第一份可见文档已经采用回调后的宽度;再刷新不得产生修正帧。
|
||
assert!(
|
||
document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.any(|mount| mount.width == 140.0 && mount.height == 20.0)
|
||
);
|
||
assert!(session.refresh_document().unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// 逐项修改示例,覆盖测量期提前读 bounds、只读范围业务写入、错误返回类型和伪造 child slot。
|
||
/// 每个非法程序都要在发布前给出对应诊断,不让不完整或不属于当前容器的排列通过。
|
||
fn source_layout_rejects_premature_geometry_writes_wrong_types_and_foreign_slots() {
|
||
// 每次仅替换一个合法源码片段,分别验证测量阶段、写入权限、返回契约与子槽归属边界。
|
||
for (before, after, message) in [
|
||
(
|
||
"return MeasureResult(parent.block.metrics.width, 60)",
|
||
"return mount.bounds()",
|
||
"geometry is unavailable during measurement",
|
||
),
|
||
(
|
||
"return MeasureResult(parent.block.metrics.width, 60)",
|
||
"parent.block.metrics.width = 260\nreturn MeasureResult(260,60)",
|
||
"read-only",
|
||
),
|
||
(
|
||
"return MeasureResult(parent.block.metrics.width, 60)",
|
||
"return Point(0,0)",
|
||
"measure return expects MeasureResult",
|
||
),
|
||
(
|
||
"positions.children.at(0).x = self.metrics.left",
|
||
"positions.children.at(0).slot = 999999",
|
||
"foreign child slot",
|
||
),
|
||
] {
|
||
// 从完整合法示例派生独立非法程序,避免前一错误留下的状态干扰下一用例。
|
||
let source = SOURCE_LAYOUT.replace(before, after);
|
||
// 在加载并首次布局时就应拒绝;诊断必须对应当前破坏的协议,不能因其他错误偶然失败。
|
||
let error = ReplSession::load(&source, "app.main", 94, 1).err().unwrap();
|
||
assert!(error.to_string().contains(message), "{error}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 表驱动检查圆形内点击、圆外但底层可点、按下释放命中不同形状,以及页面外点击。
|
||
/// 实际提交仍指向圆 Mount,worker 必须重新按源 hitTest 与 press_point 复核并选择合法目标。
|
||
fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() {
|
||
// 四组坐标分别覆盖圆内、圆外背景、跨目标释放和全页面外;两个计数表示最终回调归属。
|
||
for (press, release, expected_hits, expected_background) in [
|
||
([64.0, 64.0], [64.0, 64.0], "1", "0"),
|
||
([144.0, 64.0], [144.0, 64.0], "0", "1"),
|
||
([64.0, 64.0], [144.0, 64.0], "0", "0"),
|
||
([9999.0, 9999.0], [9999.0, 9999.0], "0", "0"),
|
||
] {
|
||
// 每组重新加载圆形命中示例,保证计数从零开始、文档版本与事件相符。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/source-hit.endor"),
|
||
"app.main",
|
||
81,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 选中可交互的圆 Mount 作为客户端声明目标;宿主还必须用源 hitTest 复核真实坐标。
|
||
let circle = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.interactive && mount.width == 160.0)
|
||
.unwrap();
|
||
// 在同一事件中携带按下与释放位置;即使释放落到背景,跨目标点击也不能误触发。
|
||
session
|
||
.dispatch(&crate::events::Input {
|
||
window: crate::window::MAIN_WINDOW,
|
||
session: initial.frame.session,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: circle.id,
|
||
kind: "click".into(),
|
||
point: Some(release),
|
||
delta: None,
|
||
press_point: Some(press),
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap();
|
||
// 读取圆的业务计数验证前景回调,再检查背景计数验证穿透或拒绝行为。
|
||
assert_eq!(
|
||
session.execute("hits.value", "app.main").unwrap().value,
|
||
expected_hits
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("background.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
expected_background
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 把 hitTest 改为返回 Number 或试图递增业务计数。
|
||
/// 派发应报告 Bool 契约/只读错误并停止运行时,但业务写入与 click 回调均未发生,hits 保持 0。
|
||
fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() {
|
||
// 分别让 hitTest 返回错误类型或试图改业务状态,验证两种错误都先于 click 回调被拦截。
|
||
for (body, message) in [
|
||
("return 1", "hitTest return expects Bool"),
|
||
(
|
||
"self.hits.increment()\nreturn point.inCircle(40, 40, 40)",
|
||
"read-only",
|
||
),
|
||
] {
|
||
// 只替换 hitTest 函数体,保留页面、监听器和命中区域这些有效前提。
|
||
let source = include_str!("../../../examples/source-hit.endor")
|
||
.replace("return point.inCircle(40, 40, 40)", body);
|
||
// 加载时无需执行命中函数,预期错误应在随后输入派发期间出现。
|
||
let (mut session, initial) = ReplSession::load(&source, "app.main", 82, 1).unwrap();
|
||
// 定位圆的出现位置,用有效版本和圆内坐标确保走到待验证的源命中协议。
|
||
let circle = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.interactive && mount.width == 160.0)
|
||
.unwrap();
|
||
// 取派发错误而非发布结果;任何业务回调执行都发生得太晚,不能挽回只读约束破坏。
|
||
let error = session
|
||
.dispatch(&crate::events::Input {
|
||
window: crate::window::MAIN_WINDOW,
|
||
session: initial.frame.session,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: circle.id,
|
||
kind: "click".into(),
|
||
point: Some([64.0, 64.0]),
|
||
delta: None,
|
||
press_point: Some([64.0, 64.0]),
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.err()
|
||
.unwrap();
|
||
// 检查错误原因和运行时停止状态,防止将静默忽略错误算作通过。
|
||
assert!(error.to_string().contains(message), "{error}");
|
||
assert!(session.is_faulted());
|
||
// 会话 faulted 后直接读取 VM,避免 execute 的停止检查遮蔽真实业务值。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 取原 hits 状态对象,核对非法写入与 click 回调均未把零改为一。
|
||
let Value::Object(hits) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("hits".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("state")
|
||
};
|
||
// 业务状态必须仍为零,证明写入在发生前被拒绝。
|
||
assert_eq!(
|
||
session.candidate.vm.get_attr(hits, "value").unwrap(),
|
||
Value::Number(0.into())
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 普通 RectBlock 通过 click 订阅变为可交互;断开令牌后新文档应移除所有交互标记。
|
||
/// 检验命中资格依赖实际监听器状态,不只由构造类型决定。
|
||
fn ordinary_block_subscription_updates_interactivity_after_disconnect() {
|
||
// 普通矩形通过连接令牌订阅 click,初始命中资格来自监听器而非专用 Button 类型。
|
||
let source = r##"hits = ui.state(0)
|
||
rect = RectBlock(80, 40, 0, "#DDEEFF")
|
||
token = rect.on("click", hits.increment)
|
||
page = Page()
|
||
page.render(rect)"##;
|
||
// 加载时建立连接并发布,先确认至少一个 Mount 被标记为可交互。
|
||
let (mut session, initial) = ReplSession::load(source, "app.main", 83, 1).unwrap();
|
||
assert!(initial.frame.mounts.iter().any(|mount| mount.interactive));
|
||
// 从源语言断开唯一监听器;连接变化应触发新文档并清除交互标记。
|
||
let result = session.execute("token.disconnect()", "app.main").unwrap();
|
||
// 遍历新文档的全部出现位置,不能只清除某一层或遗漏缓存中的交互状态。
|
||
assert!(
|
||
result
|
||
.document
|
||
.unwrap()
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.all(|mount| !mount.interactive)
|
||
);
|
||
}
|
||
|
||
/// 生成可调规模的源 paint 压力程序:copies 控制共享对象出现次数,locals 控制单次 paint 指令量。
|
||
/// onMount 计数检测取消/恢复是否重复生命周期,tint 让各出现位置绘制结果可统一断言。
|
||
fn resumable_paint_source(copies: usize, locals: usize) -> String {
|
||
// 下方布局/绘制切片测试调用本生成器;重复同一业务引用,扩大 Mount 数而不扩大业务对象数。
|
||
let children = std::iter::repeat_n("item", copies)
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
// 重复无副作用的局部赋值以消耗 VM 指令预算,让单个 paint 也能跨片暂停。
|
||
let locals = "probe = 2\n".repeat(locals);
|
||
// 生成完整可加载程序:挂载次数与可变颜色是后续取消、恢复和错误测试的观测点。
|
||
format!(
|
||
"class Painted: RectBlock {{\n tint: String\n mounted: Object\n function onMount(self: Painted, mount: Mount): Unit {{ self.mounted.increment() }}\n function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {{\n{locals}drawing = RectBlock.paint(self,mount,geometry)\ndrawing.commands.at(0).color = self.tint\nreturn drawing\n}}\n}}\nmounted = ui.state(0)\nitem = Painted(2,20,0,\"#DDEEFF\",\"#ED6A5A\",mounted)\npage = Page()\npage.render(Row(0,0,{children}))"
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
/// 分别加载含源布局/源绘制的示例并主动 reclaim 临时辅助对象。
|
||
/// 随后无业务变化刷新必须为 None,版本不变,证明缓存未把短命 helper 当作业务失效依赖。
|
||
fn collecting_lazy_helpers_does_not_invalidate_stable_layout_or_paint() {
|
||
// 分别覆盖源布局辅助对象和源绘制辅助对象,不把两套依赖机制混成一个用例。
|
||
for source in [
|
||
include_str!("../../../examples/flex-constraints.endor"),
|
||
include_str!("../../../examples/source-paint.endor"),
|
||
] {
|
||
// 完成一次正常发布,让临时辅助对象及相应缓存都已产生。
|
||
let (mut session, initial) = ReplSession::load(source, "app.main", 281, 1).unwrap();
|
||
// 显式执行桥接层回收,保留会话业务根;回收短命 helper 不应被解释为业务状态变化。
|
||
session
|
||
.bridge
|
||
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
|
||
.unwrap();
|
||
// 检查没有发布动作,再比对保存的版本,防止仅返回 None 却内部替换文档。
|
||
assert!(session.refresh_document().unwrap().is_none());
|
||
assert_eq!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version,
|
||
initial.frame.layout_version
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 用 160/640 个出现位置强制布局和绘制让出一次执行。
|
||
/// 首次 Pending 时托管对象增长必须小于固定阈值,不能预建整树 Bounds/collector;显式取消后续体和只读范围都应关闭。
|
||
fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() {
|
||
// 以两种明显不同的树规模验证准备阶段的增量内存界限,阈值不随总出现次数增长。
|
||
for copies in [160, 640] {
|
||
// 先构造有效完整帧,随后直接调用桥接分片入口以观察首次 Pending 的中间状态。
|
||
let (mut session, initial) =
|
||
ReplSession::load(&resumable_paint_source(copies, 0), "app.main", 280, 1).unwrap();
|
||
// 记录开始绘制前的 VM 对象数;只计算本次准备与第一片新增的托管辅助对象。
|
||
let before = session.candidate.vm.object_count();
|
||
// 对已布局的根和帧启动源绘制,避免把布局开销计入绘制阶段。
|
||
let progress = session
|
||
.bridge
|
||
.begin_paint_frame(
|
||
&mut session.candidate.vm,
|
||
initial.ui_root.clone(),
|
||
initial.frame,
|
||
)
|
||
.unwrap();
|
||
// 必须真实让出执行;若一次完成,就没有覆盖待取消的绘制续体。
|
||
let crate::source_paint::Progress::Pending(work) = progress else {
|
||
panic!("paint must yield");
|
||
};
|
||
// 在首次 Pending 处检查相对 baseline 的托管对象数净增小于 256;不统计原生内存或该片中间的分配峰值。
|
||
// 两种树规模共用阈值,用于发现首次让出前为整树预建并保留 Bounds/collector 的退化。
|
||
assert!(session.candidate.vm.object_count() < before + 256);
|
||
// 取消未完成帧并检查 VM 续体释放,随后才能独立测试布局路径。
|
||
session
|
||
.bridge
|
||
.cancel_paint_frame(&mut session.candidate.vm, crate::window::MAIN_WINDOW, *work)
|
||
.unwrap();
|
||
assert!(!session.candidate.vm.has_suspended_call());
|
||
// 改变可用视口,使接下来的布局不能直接沿用原来的约束结果。
|
||
session
|
||
.bridge
|
||
.set_viewport(
|
||
&mut session.candidate.vm,
|
||
Some(crate::layout::Size {
|
||
width: 900.0,
|
||
height: 650.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 准备页面根但不一次性完成布局,后续观察 begin_layout_frame 的第一片。
|
||
let root = session
|
||
.bridge
|
||
.prepare_frame(&mut session.candidate.vm)
|
||
.unwrap()
|
||
.unwrap();
|
||
// 重新取布局阶段基线,排除前一绘制测试已有对象对增长量的影响。
|
||
let before = session.candidate.vm.object_count();
|
||
// 只启动布局第一片,不预先为整棵树创建 Bounds/collector。
|
||
let progress = session
|
||
.bridge
|
||
.begin_layout_frame(&mut session.candidate.vm, root)
|
||
.unwrap();
|
||
// 要求布局也实际暂停,随后检查此暂停点的托管对象数净增;这是独立布局 baseline,不累计绘制阶段。
|
||
let crate::ui_bridge::LayoutProgress::Pending(work) = progress else {
|
||
panic!("layout must yield");
|
||
};
|
||
assert!(session.candidate.vm.object_count() < before + 256);
|
||
// 取消布局续体;最后同时核对暂停调用和只读范围均已关闭,避免影响后续业务执行。
|
||
session
|
||
.bridge
|
||
.cancel_layout_frame(&mut session.candidate.vm, *work)
|
||
.unwrap();
|
||
assert!(!session.candidate.vm.has_suspended_call());
|
||
assert!(!session.candidate.vm.object_store().is_read_only());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 修改共享 tint 后逐片推进到 paint 挂起,检查旧完整文档仍可见。
|
||
/// 换视口取消未发布绘制并重算,最终 80 条命令全为新色,onMount 仍恰好 80 次,取消与切片统计可观察。
|
||
fn pending_paint_retargets_without_replaying_completed_mount_hooks() {
|
||
// 80 个出现位置已完成首次 onMount;后续只改颜色,不应再调用这些生命周期钩子。
|
||
let (mut session, initial) =
|
||
ReplSession::load(&resumable_paint_source(80, 0), "app.main", 278, 1).unwrap();
|
||
// 空视口更新表表示沿用已有尺寸,让本轮先推进到可取消的源绘制阶段。
|
||
let viewports = std::collections::BTreeMap::new();
|
||
// 提交业务颜色变化但采用增量执行,保留观察未完成更新的机会。
|
||
session
|
||
.execute_incremental("item.tint = \"#526AC7\"", "app.main", &viewports)
|
||
.unwrap();
|
||
// 有界推进刷新各阶段,等待绘制工作出现;超出上限后由下面断言报告未覆盖暂停路径。
|
||
for _ in 0..100 {
|
||
session
|
||
.resize_windows_incremental(&viewports, false)
|
||
.unwrap();
|
||
// 只在刷新状态已有 paint 续体时停止,不能把仍在布局中的暂停误算为绘制暂停。
|
||
if session
|
||
.refresh
|
||
.as_ref()
|
||
.is_some_and(|work| work.paint.is_some())
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
// 确认刷新状态与 VM 实际暂停调用一致,随后验证尚未发布半成品。
|
||
assert!(
|
||
session
|
||
.refresh
|
||
.as_ref()
|
||
.is_some_and(|work| work.paint.is_some())
|
||
);
|
||
assert!(session.candidate.vm.has_suspended_call());
|
||
// 绘制未完成时应继续展示初始版本,所有旧命令仍保持初始颜色。
|
||
assert_eq!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version,
|
||
initial.frame.layout_version
|
||
);
|
||
assert!(session.documents[&crate::window::MAIN_WINDOW].frame.commands.iter().all(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#ED6A5A")));
|
||
// 在旧 paint 暂停时提供新尺寸,迫使刷新取消旧目标并基于最新约束重新准备。
|
||
let latest = std::collections::BTreeMap::from([(
|
||
crate::window::MAIN_WINDOW,
|
||
crate::layout::Size {
|
||
width: 900.0,
|
||
height: 650.0,
|
||
},
|
||
)]);
|
||
// 先只执行一次重定向,确认旧的 VM 暂停调用已被清除。
|
||
session.resize_windows_incremental(&latest, false).unwrap();
|
||
assert!(!session.candidate.vm.has_suspended_call());
|
||
// 用同步入口推进到收敛,检查最终整帧全部为新色且没有丢失出现位置。
|
||
let settled = session.resize_windows(&latest).unwrap().unwrap();
|
||
assert_eq!(settled.frame.commands.len(), 80);
|
||
assert!(settled.frame.commands.iter().all(
|
||
|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#526AC7")
|
||
));
|
||
// 业务挂载计数仍为首次的 80;重算绘制不能重放已完成的 onMount。
|
||
assert_eq!(
|
||
session.execute("mounted.value", "app.main").unwrap().value,
|
||
"80"
|
||
);
|
||
// paintCancels 是此会话累计取消计数,paintSlices 对应最近完成的 solve;两者不是同一时间范围的统计。
|
||
// 首次加载后本例仅重定向一次,因此应恰好取消一次;完成的绘制应确实跨过多个切片。
|
||
assert_eq!(source_measure_stat(&mut session, "paintCancels"), 1);
|
||
assert!(source_measure_stat(&mut session, "paintSlices") > 1);
|
||
}
|
||
|
||
#[test]
|
||
/// 利用长 paint 先产生暂停,再在解码时遇到非法颜色。
|
||
/// 必须同时验证曾挂起及最终失败;故障关闭 VM 续体/只读范围,保留旧文档版本和原始绘制颜色。
|
||
fn paint_error_after_pause_closes_read_only_scope_and_preserves_last_picture() {
|
||
// 单个 paint 内放入 300 次局部赋值,让命令解码报错之前有机会真实暂停。
|
||
let (mut session, initial) =
|
||
ReplSession::load(&resumable_paint_source(1, 300), "app.main", 279, 1).unwrap();
|
||
// 保持当前视口,将变量收敛到“暂停后绘制出错”这一条路径。
|
||
let viewports = std::collections::BTreeMap::new();
|
||
// 业务字段本身可接受字符串;非法颜色会在后续 paint 命令解码时被拒绝。
|
||
session
|
||
.execute_incremental("item.tint = \"invalid-color\"", "app.main", &viewports)
|
||
.unwrap();
|
||
// 累计是否曾观察到 paint 续体,防止只测到同步失败。
|
||
let mut paused = false;
|
||
// 另记最终是否失败,两项必须同时成立才覆盖暂停后的错误清理。
|
||
let mut failed = false;
|
||
// 有界推进至错误;每个成功切片检查刷新状态,错误分支立即结束推进。
|
||
for _ in 0..100 {
|
||
// 成功仍可能表示尚未完成,不能以 Ok 直接判定新帧已经发布。
|
||
match session.resize_windows_incremental(&viewports, false) {
|
||
// 累计 paint 曾挂起的事实;后续阶段清除 refresh 时也保留该证据。
|
||
Ok(_) => {
|
||
paused |= session
|
||
.refresh
|
||
.as_ref()
|
||
.is_some_and(|work| work.paint.is_some())
|
||
}
|
||
// 记录错误并停止使用这次已故障的刷新,后面的断言检查清理结果。
|
||
Err(_) => {
|
||
failed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// 要求两个阶段均被覆盖;故障后 VM 不能残留暂停调用或只读范围。
|
||
assert!(paused && failed);
|
||
assert!(session.is_faulted());
|
||
assert!(!session.candidate.vm.has_suspended_call());
|
||
assert!(!session.candidate.vm.object_store().is_read_only());
|
||
// 对外仍保留最后成功发布的版本和颜色,不暴露无效颜色生成的半帧。
|
||
assert_eq!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version,
|
||
initial.frame.layout_version
|
||
);
|
||
assert!(session.documents[&crate::window::MAIN_WINDOW].frame.commands.iter().all(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#ED6A5A")));
|
||
}
|
||
|
||
#[test]
|
||
/// paint 局部变量先引用旧 ObjectList,再重新绑定到新 DisplayList。
|
||
/// 允许局部引用重绑不等于允许修改旧业务对象;绘制成功后 previous 仍应为空。
|
||
fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it() {
|
||
// 局部 drawing 先引用旧列表、后改指向新 DisplayList;用于区分引用重绑与旧对象字段写入。
|
||
let source = r##"class ReboundRect: RectBlock {
|
||
previous: ObjectList
|
||
function paint(self: ReboundRect, mount: Mount, geometry: Bounds): DisplayList {
|
||
drawing = self.previous
|
||
drawing = RectBlock.paint(self, mount, geometry)
|
||
return drawing
|
||
}
|
||
}
|
||
previous = ObjectList()
|
||
rect = ReboundRect(80, 40, 0, "#DDEEFF", previous)
|
||
page = Page()
|
||
page.render(rect)"##;
|
||
// 加载过程执行 paint;成功生成一条命令,证明局部变量赋值未被只读策略误拒绝。
|
||
let (mut session, document) = ReplSession::load(source, "app.main", 75, 1).unwrap();
|
||
assert_eq!(document.frame.commands.len(), 1);
|
||
// 重新读取旧列表长度,确认没有因为重绑而意外往旧业务集合写入命令。
|
||
assert_eq!(
|
||
session.execute("previous.count", "app.main").unwrap().value,
|
||
"0"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 源 paint 修改原生基类返回的命令颜色,检查共享对象各出现位置均使用覆盖结果。
|
||
/// 直接写堆上的 tint 而不执行 REPL,只靠 paint 的字段依赖就应触发重绘并在下一轮稳定。
|
||
fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() {
|
||
// 加载共享矩形的源绘制示例,让 paint 对 tint 的读取建立真实依赖。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/source-paint.endor"),
|
||
"app.main",
|
||
71,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先验证文档结构,再核对两处矩形及文字的源绘制颜色,确保覆盖生效。
|
||
initial.validate().unwrap();
|
||
assert_eq!(initial.frame.commands.iter().filter(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#ED6A5A")).count(), 2);
|
||
assert!(
|
||
initial.frame.commands.iter().any(
|
||
|command| matches!(command, RenderCommand::Text { color, .. } if color == "#FFFFFF")
|
||
)
|
||
);
|
||
// 直接取得被 paint 读取的业务矩形对象,不通过重新执行页面源码来刷新。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 对象身份必须来自原会话,使 set_attr 只改变已有字段并触发其依赖。
|
||
let Value::Object(rect) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("rect".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Block")
|
||
};
|
||
// 不经 Console、不写全局绑定、不改焦点,只改变 paint 读过的属性,单独验证该读依赖足以使帧失效。
|
||
session
|
||
.candidate
|
||
.vm
|
||
.set_attr(rect, "tint", Value::Text("#526AC7".into()))
|
||
.unwrap();
|
||
// 仅靠写观察驱动刷新,两处共享出现位置都必须更新;再次刷新应稳定。
|
||
let updated = session.refresh_document().unwrap().unwrap();
|
||
assert_eq!(updated.frame.commands.iter().filter(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#526AC7")).count(), 2);
|
||
assert!(session.refresh_document().unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// paint 尝试写 self.tint,必须在写入前被只读保护拒绝并令会话 fault。
|
||
/// 原业务颜色不变,错误展开后宿主可正常直接写属性;另测非法颜色命令的发布拒绝。
|
||
fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() {
|
||
// paint 正在只读范围内执行;夹具尝试修改早已存在的 rect.tint,而非刚创建的绘制结果。
|
||
let source = r##"class BadPaint: RectBlock {
|
||
tint: String
|
||
function paint(self: BadPaint, mount: Mount, geometry: Bounds): DisplayList {
|
||
self.tint = "#000000"
|
||
return RectBlock.paint(self, mount, geometry)
|
||
}
|
||
}
|
||
rect = BadPaint(100, 40, 0, "#DDEEFF", "#FF0000")
|
||
page = Page()
|
||
page.render(rect)"##;
|
||
// 先建立会话再执行,错误后仍能检查候选 VM 中的对象与保护范围。
|
||
let mut session = ReplSession::new(72).unwrap();
|
||
// 页面加载触发首帧 paint,要求诊断明确来自 read-only 保护。
|
||
assert!(
|
||
session
|
||
.execute(source, "app.main")
|
||
.err()
|
||
.unwrap()
|
||
.to_string()
|
||
.contains("read-only")
|
||
);
|
||
// 绘制失败应停止当前运行时;但停止状态不能替代检查写入确实没有发生。
|
||
assert!(session.is_faulted());
|
||
// 绕过已停止会话的 execute 入口,直接取原业务对象验证错误后的真实状态。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 全局 rect 应已在 paint 前构造完毕,必须保留其原身份与字段值。
|
||
let Value::Object(rect) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("rect".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Block")
|
||
};
|
||
// tint 仍是构造时的红色,说明非法写入在发生前被阻止,而非失败后仅停止后续回调。
|
||
assert_eq!(
|
||
session.candidate.vm.get_attr(rect, "tint").unwrap(),
|
||
Value::Text("#FF0000".into())
|
||
);
|
||
// 宿主直接写字段应恢复为可写,验证异常展开已退出 paint 的只读范围。
|
||
session
|
||
.candidate
|
||
.vm
|
||
.set_attr(rect, "tint", Value::Text("#526AC7".into()))
|
||
.unwrap();
|
||
// 另用独立加载检查无效颜色的命令解码错误,与上面的业务写入拒绝分开覆盖。
|
||
let bad_color =
|
||
include_str!("../../../examples/source-paint.endor").replace("#ED6A5A", "invalid color");
|
||
// 这里只断言加载失败,没有核对诊断内容;不能单凭此断言证明失败一定来自颜色解码。
|
||
assert!(ReplSession::load(&bad_color, "app.main", 73, 1).is_err());
|
||
}
|
||
|
||
#[test]
|
||
/// 生成 1100 个共享 rect 出现位置,对有限容量的 Mount 变更批次施加压力。
|
||
/// 本测试不读取溢出标志或批次条数;直接断言的是命令完整性与下一次空刷新稳定性。
|
||
/// 首次仍需完整产出 1100 条命令,随后无变更刷新为 None,避免通知溢出造成永久重绘。
|
||
fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() {
|
||
// 复用源 paint 类与共享 rect 的定义,保留实际依赖读取行为。
|
||
let fixture = include_str!("../../../examples/source-paint.endor");
|
||
// 截去原根容器及后续页面代码,随后构造大量共享出现位置。
|
||
let mut source = fixture[..fixture.find("root = Column").unwrap()].to_owned();
|
||
// 创建一个无内边距和间距的 Column,使命令数量直接对应共享 rect 的出现次数。
|
||
source.push_str("root = Column(0, 0");
|
||
// 每次追加同一业务引用,扩大 Mount 及相关属性通知规模;1100 是出现数,不是直接测得的通知条数。
|
||
for _ in 0..1100 {
|
||
source.push_str(", rect");
|
||
}
|
||
// 补齐页面入口,确保所有出现位置都经过正常挂载和源绘制。
|
||
source.push_str(")\npage = Page()\npage.render(root)");
|
||
// 完成初始发布后检查没有遗漏命令,随后无变化刷新应为 None,不能持续误判失效。
|
||
let (mut session, document) = ReplSession::load(&source, "app.main", 74, 1).unwrap();
|
||
assert_eq!(document.frame.commands.len(), 1100);
|
||
assert!(session.refresh_document().unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// 检查可选原生构造参数、可变子项和源子类字段在同一构造中的独立分段。
|
||
/// 错误调用被拒绝且最终 Block 数量不增;合法回调有效,并验证下方显式列出的 UI 方法具有有效 native signature。
|
||
fn constructor_options_and_subclass_fields_have_independent_contracts() {
|
||
// 加载省略可选原生参数、带源子类字段及可变子项的组合示例,走真实构造入口。
|
||
let (mut session, document) = ReplSession::load(
|
||
include_str!("../../../examples/constructor-contracts.endor"),
|
||
"app.main",
|
||
166,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 文档必须结构有效,再核对字段位置与类型,防止构造参数分段串位。
|
||
document.validate().unwrap();
|
||
// 首组逐项读取原生默认值、源字段、事件和列表成员,验证各段参数各归其位。
|
||
for (expression, expected) in [
|
||
("rect.width", "180"),
|
||
("rect.radius", "0"),
|
||
("rect.tag", "\"省略 radius/color 后的字段\""),
|
||
("root.tag", "\"可变子项之后的字段\""),
|
||
("event.kind", "\"click\""),
|
||
("items.count", "2"),
|
||
("ui.isInstance(items.at(0), TaggedRect)", "true"),
|
||
] {
|
||
// 同一会话内查询实际业务值;失败时附表达式,便于定位哪一段构造规则出错。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
expected,
|
||
"{expression}"
|
||
);
|
||
}
|
||
// 保存非法调用前的 Block 数量,后面比较最终保留数;计数相同本身不能排除中途分配后又回收。
|
||
let blocks = session
|
||
.execute("ui.stats().blocks", "app.main")
|
||
.unwrap()
|
||
.value;
|
||
// 分别破坏原生参数、可变子项、源子类构造和 Type 参数,要求报告对应契约。
|
||
for (expression, expected) in [
|
||
("TaggedRect(\"bad\", 60, \"tag\")", "expects Number"),
|
||
("TaggedColumn(0, 0, 7, \"tag\")", "expects Block"),
|
||
("ui.rect(1, 2, \"bad\")", "expects Number"),
|
||
("ui.text(7)", "expects String"),
|
||
("TaggedEvent(7, \"tag\")", "expects String"),
|
||
("ui.isSubtype(7, Block)", "expects Type"),
|
||
] {
|
||
// 错误调用必须返回 Err;不能靠无输出或错误对象被悄悄忽略来通过。
|
||
let error = session
|
||
.execute(expression, "app.main")
|
||
.err()
|
||
.expect("invalid typed constructor/API");
|
||
assert!(
|
||
error.to_string().contains(expected),
|
||
"{expression}: {error:#}"
|
||
);
|
||
}
|
||
// 所有错误尝试结束后,业务 Block 数必须等于基线。
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.stats().blocks", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
blocks
|
||
);
|
||
// 合法回调继续可用,证明这些参数错误不会把健康会话永久停止。
|
||
session.execute("button.click()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session.execute("rect.color", "app.main").unwrap().value,
|
||
"\"#237A45\""
|
||
);
|
||
// 检查下方固定名单的签名元数据;没有遍历 ui 全部属性,因此不是所有公开入口的完整覆盖。
|
||
for name in [
|
||
"open",
|
||
"present",
|
||
"text",
|
||
"rect",
|
||
"spacer",
|
||
"row",
|
||
"column",
|
||
"button",
|
||
"page",
|
||
"state",
|
||
"textOf",
|
||
"stats",
|
||
"viewport",
|
||
"window",
|
||
"isInstance",
|
||
"isSubtype",
|
||
"implement",
|
||
"implements",
|
||
"supports",
|
||
"after",
|
||
] {
|
||
// 从当前桥接 ui 对象取得真实函数引用,避免验证一份与实际发布函数无关的描述。
|
||
let Value::Object(function) = session
|
||
.candidate
|
||
.vm
|
||
.get_attr(session.bridge.ui, name)
|
||
.unwrap()
|
||
else {
|
||
panic!("native Function")
|
||
};
|
||
// 读取对象存储中绑定到该 native 函数的签名;缺失元数据也必须使测试失败。
|
||
let signature = session
|
||
.candidate
|
||
.vm
|
||
.object_store()
|
||
.native_signature(function)
|
||
.unwrap()
|
||
.expect("published UI signature");
|
||
// 使用同一 VM 类型表验证签名引用与结构,确保这些契约可由运行时实际使用。
|
||
signature
|
||
.validate(session.candidate.vm.object_store())
|
||
.unwrap();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 加载满足参数逆变/返回协变的视觉方法覆盖,验证测量和点击都按动态类型执行。
|
||
/// 再分别缩窄参数、放宽返回类型,检查类定义被对应 override 诊断拒绝。
|
||
fn source_overrides_accept_wider_inputs_and_more_specific_results() {
|
||
// 示例中 WideRect 放宽 measure 参数为 Object,返回更具体的 Sized,保留合法覆写基线。
|
||
let source = include_str!("../../../examples/method-contracts.endor");
|
||
// 正常加载时解析覆写契约并执行动态 measure,先确认合法定义可发布。
|
||
let (mut session, document) = ReplSession::load(source, "app.main", 164, 1).unwrap();
|
||
// 初始几何通过源 Mount API 读取,不能只凭类定义成功判定动态调用正确。
|
||
document.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("rect.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"180"
|
||
);
|
||
// 通过合法按钮回调改变原矩形宽度,再核对动态测量更新和一次点击计数。
|
||
session.execute("button.click()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("rect.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"260"
|
||
);
|
||
assert_eq!(
|
||
session.execute("count.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
// 从合法程序分别派生“参数变窄”和“返回变宽”,各用独立加载隔离错误。
|
||
for (source, reason) in [
|
||
(
|
||
source.replace("mount: Object", "mount: Point"),
|
||
"override narrows a parameter type",
|
||
),
|
||
(
|
||
source.replace("): Sized {", "): Object {"),
|
||
"override widens the return type",
|
||
),
|
||
] {
|
||
// 错误必须指向覆写的方差规则,而不是任意加载失败。
|
||
let error = ReplSession::load(&source, "app.main", 165, 1)
|
||
.err()
|
||
.expect("invalid override");
|
||
assert!(error.to_string().contains(reason), "{error:#}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 把 Mount/Bounds 的名义类型、事件 Bool|String 选项及可空 Page.window/open 参数串联执行。
|
||
/// 合法空值和 union 分支互通,Number 选项则必须以 Bool | String 契约诊断拒绝。
|
||
fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() {
|
||
// 加载完整视觉协议示例,准备有效矩形、父容器和已打开的页面上下文。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/visual-protocols.endor"),
|
||
"app.main",
|
||
163,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 按顺序执行:先保存 Mount,再测试 Bounds、联合选项、断连及可空页面窗口。
|
||
// None 表示只要求调用成功;Some 才对序列化后的返回值作精确断言。
|
||
for (expression, expected) in [
|
||
("context = rect.mounts().at(0)", None),
|
||
("ui.isInstance(context.bounds(), Bounds)", Some("true")),
|
||
("Point(0, 0).inside(context.bounds())", Some("false")),
|
||
(
|
||
"listener = root.on(\"click\", page.close, Point(0, 0).inCircle(0, 0, 1))",
|
||
None,
|
||
),
|
||
("listener.isConnected()", Some("true")),
|
||
("listener.disconnect()", Some("nil")),
|
||
("listener.isConnected()", Some("false")),
|
||
("page.close()", Some("nil")),
|
||
("page.window()", Some("nil")),
|
||
("page.open(page.window())", None),
|
||
("ui.isInstance(page.window(), Window)", Some("true")),
|
||
] {
|
||
// 各表达式共享前一步建立的 context/listener;顺序是本用例的一部分。
|
||
let result = session.execute(expression, "app.main").unwrap();
|
||
// 只比较显式声明的预期值,赋值语句等步骤主要用于建立后续检查条件。
|
||
if let Some(expected) = expected {
|
||
assert_eq!(result.value, expected, "{expression}");
|
||
}
|
||
}
|
||
// 数值 7 不属于 Bool | String 的任何分支,注册应给出联合类型诊断。
|
||
let error = session
|
||
.execute("root.on(\"click\", page.close, 7)", "app.main")
|
||
.err()
|
||
.expect("invalid union input");
|
||
assert!(error.to_string().contains("Bool | String"), "{error:#}");
|
||
}
|
||
|
||
#[test]
|
||
/// 向 measure/paint、几何方法、resize 和 setColor 传递错误名义类型。
|
||
/// 检查每项错误点名所需类型,保存的颜色探测值不变,随后合法 Constraints 测量仍返回受限宽度。
|
||
fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() {
|
||
// 先加载合法页面,以有效业务对象为前提逐项测试原生接口的参数类型边界。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/visual-protocols.endor"),
|
||
"app.main",
|
||
162,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 保存属于 rect 的真实 Mount;错误用例只破坏指定参数,不混入失效上下文。
|
||
session
|
||
.execute("context = rect.mounts().at(0)", "app.main")
|
||
.unwrap();
|
||
// 覆盖 measure/paint 的名义类型、几何运算、窗口尺寸及颜色参数,分别要求对应类型名。
|
||
for (expression, expected) in [
|
||
("rect.measure(context, Point(0, 0))", "expects Constraints"),
|
||
(
|
||
"rect.measure(Point(0, 0), Constraints(0, 0, 120, 80))",
|
||
"expects Mount",
|
||
),
|
||
("rect.paint(context, Point(0, 0))", "expects Bounds"),
|
||
(
|
||
"Point(0, 0).inside(Constraints(0, 0, 120, 80))",
|
||
"expects Bounds",
|
||
),
|
||
("Point(0, 0).inCircle(0, 0, \"bad\")", "expects Number"),
|
||
("ui.window().resize(\"bad\", 700)", "expects Number"),
|
||
("rect.setColor(7)", "expects String"),
|
||
] {
|
||
// 每个非法调用必须返回契约错误,错误消息需区分具体被拒绝的参数。
|
||
let error = session
|
||
.execute(expression, "app.main")
|
||
.err()
|
||
.expect("invalid native call");
|
||
assert!(
|
||
error.to_string().contains(expected),
|
||
"{expression}: {error:#}"
|
||
);
|
||
}
|
||
// results.color 是 onMount 保存的探测值,不是重新读取 rect 当前颜色;此断言不单独证明 rect.color 未变。
|
||
// 随后的合法 Constraints 测量验证会话继续可用。
|
||
assert_eq!(
|
||
session.execute("results.color", "app.main").unwrap().value,
|
||
"\"#DDEEFF\""
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute(
|
||
"rect.measure(context, Constraints(0, 0, 120, 80)).width",
|
||
"app.main"
|
||
)
|
||
.unwrap()
|
||
.value,
|
||
"120"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 执行示例内直接调用的原生视觉协议,比较托管 MeasureResult/Arrangement/DisplayList 与真实发布命令。
|
||
/// 局部命中允许点内/点外判断,但非法约束、错属 Mount 和已卸载上下文不得用于绘制。
|
||
fn native_visual_methods_use_managed_values_and_match_real_geometry() {
|
||
// 示例直接调用原生 measure/arrange/paint,并保存托管结果,供本测试与真实首帧对比。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/visual-protocols.endor"),
|
||
"app.main",
|
||
61,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 确认文档合法后比较保存结果,覆盖原生协议返回值解码及其名义托管类型。
|
||
initial.validate().unwrap();
|
||
// 依次检查受限测量尺寸、子项排列坐标和绘制颜色,保留各协议独立观察点。
|
||
for (expression, expected) in [
|
||
("results.width", "120"),
|
||
("results.height", "60"),
|
||
("results.childX", "44"),
|
||
("results.childY", "54"),
|
||
("results.color", "\"#DDEEFF\""),
|
||
] {
|
||
// 读取示例执行时保存的结果,避免在断言时重新调用协议改变待检数据。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
expected
|
||
);
|
||
}
|
||
// 实际发布的矩形使用页面布局分配的 200×60,而直接受限测量返回 120;两种上下文不能混淆。
|
||
assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { width: 200.0, height: 60.0, color, .. } if color == "#DDEEFF")));
|
||
// 取已发布 rect 的 Mount,后续命中与绘制检查都用这个有效的所属上下文。
|
||
session
|
||
.execute("context = rect.mounts().at(0)", "app.main")
|
||
.unwrap();
|
||
// 本地坐标 (1,1) 位于矩形内,(-1,1) 位于外部,原生 hitTest 应分别返回 true/false。
|
||
assert_eq!(
|
||
session
|
||
.execute("rect.hitTest(context, Point(1, 1))", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("rect.hitTest(context, Point(-1, 1))", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"false"
|
||
);
|
||
// Constraints 的最小宽度大于最大宽度,即使参数类型正确也必须拒绝。
|
||
assert!(
|
||
session
|
||
.execute(
|
||
"rect.measure(context, Constraints(200, 0, 100, 100))",
|
||
"app.main"
|
||
)
|
||
.is_err()
|
||
);
|
||
// rect 的 Mount 不属于 root,类型正确仍不能越过所属对象检查。
|
||
assert!(
|
||
session
|
||
.execute("root.paint(context, Bounds(0, 0, 10, 10))", "app.main")
|
||
.is_err()
|
||
);
|
||
// 关闭页面后旧 Mount 已卸载,原 rect 也不能继续用这个上下文绘制。
|
||
session.execute("page.close()", "app.main").unwrap();
|
||
assert!(
|
||
session
|
||
.execute("rect.paint(context, Bounds(0, 0, 10, 10))", "app.main")
|
||
.is_err()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 先 invalidate paint 再 invalidate layout,对比 measured 为 0 和 1。
|
||
/// 两者都可发布但不替换 Mount;无关查询不发布,未知失效类别明确报错。
|
||
fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when_requested() {
|
||
// 以稳定已发布页面为起点,单独观察显式 paint/layout 失效请求的不同工作量。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/visual-protocols.endor"),
|
||
"app.main",
|
||
62,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 在源全局保存原 Mount 引用,供失效后的身份比较使用。
|
||
session
|
||
.execute("context = rect.mounts().at(0)", "app.main")
|
||
.unwrap();
|
||
// 读取原引用的显示值作为身份基线,避免仅凭相同几何误认同一出现位置。
|
||
let original = session.execute("context", "app.main").unwrap().value;
|
||
// 仅使 paint 失效也应发布新文档,但 measured 统计必须为零。
|
||
let update = session
|
||
.execute("rect.invalidate(\"paint\")", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 检查新文档有效,再读取本次更新的测量数,不把发布成功当作未重测的证据。
|
||
update.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.stats().measured", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
// 显式 layout 失效应发布并重新测量,随后统计要求恰好一个测量。
|
||
assert!(
|
||
session
|
||
.execute("rect.invalidate(\"layout\")", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_some()
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.stats().measured", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"1"
|
||
);
|
||
// 两种失效都应保留原 Mount 身份,不通过销毁重建来刷新。
|
||
assert_eq!(
|
||
session
|
||
.execute("rect.mounts().at(0)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
original
|
||
);
|
||
// 无关的只读查询不能再发布一帧,验证失效已被消费完。
|
||
assert!(
|
||
session
|
||
.execute("results.width", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_none()
|
||
);
|
||
// 未知失效类别应明确报错,不能默认为最宽泛的重排而掩盖调用错误。
|
||
assert!(
|
||
session
|
||
.execute("rect.invalidate(\"unknown\")", "app.main")
|
||
.is_err()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 加载共享子项生命周期示例,通过日志计数检查每个出现位置各自挂载。
|
||
/// 普通颜色更新不重复 onMount;关闭时子先父后卸载,保存的旧 Mount 永久失效,重开创建新出现位置。
|
||
fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() {
|
||
// 共享 child 在树中出现两次;生命周期日志区分业务对象与每个独立出现位置。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/mount-lifecycle.endor"),
|
||
"app.main",
|
||
51,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 首帧已完成钩子引起的状态变化,文档应有效且日志顺序已经稳定。
|
||
initial.validate().unwrap();
|
||
// 父先挂载,两个 child 出现随后挂载;最后一个子通知序号为 3。
|
||
for (expression, expected) in [
|
||
("log.mounted.value", "2"),
|
||
("log.parentMounted", "1"),
|
||
("log.childMounted", "3"),
|
||
] {
|
||
// 逐项读取真实业务日志,避免只比较总次数而遗漏父子通知顺序。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
expected
|
||
);
|
||
}
|
||
// 保存旧 Mount 后仅改颜色;这是普通数据刷新,不能再次调用 onMount。
|
||
session
|
||
.execute(
|
||
"saved = child.mounts().at(0)\nchild.setColor(\"#237A45\")",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("log.mounted.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
// 关闭页面应发布空 Mount 树,并对两个 child 出现各执行一次 onUnmount。
|
||
let closed = session
|
||
.execute("page.close()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(closed.frame.mounts.is_empty());
|
||
assert_eq!(
|
||
session
|
||
.execute("log.unmounted.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
// 最后一个 child 卸载序号为 5,父卸载为 6,验证卸载阶段子先父后的顺序。
|
||
assert_eq!(
|
||
session
|
||
.execute("log.childUnmounted", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"5"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("log.parentUnmounted", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"6"
|
||
);
|
||
// 保留的旧句柄可查询 mounted=false,但不允许再读取有效布局边界。
|
||
assert_eq!(
|
||
session.execute("saved.mounted", "app.main").unwrap().value,
|
||
"false"
|
||
);
|
||
assert!(session.execute("saved.bounds()", "app.main").is_err());
|
||
// 重开页面创建新出现位置,总挂载次数增为 4;旧句柄仍不能复活。
|
||
session.execute("page.open()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("log.mounted.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"4"
|
||
);
|
||
assert_eq!(
|
||
session.execute("saved.mounted", "app.main").unwrap().value,
|
||
"false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 让两个页面使用同一业务根,然后切换活动页面。
|
||
/// 每次切换都卸载旧上下文并挂载新上下文;共享 Block 不能让旧 Mount 或 page() 指向继续复用。
|
||
fn page_switch_replaces_mount_context_even_for_shared_root() {
|
||
// 先打开包含共享 child 的初始页面,建立两个有效 Mount 及其日志基线。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/mount-lifecycle.endor"),
|
||
"app.main",
|
||
52,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 保存旧上下文,再让第二个页面使用同一业务根;活动页面切换仍必须重建 Mount 上下文。
|
||
session
|
||
.execute(
|
||
"saved = child.mounts().at(0)\nsecond = Page()\nsecond.render(root)",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 新页面挂载两次、旧页面卸载两次;共享根不能使页面之间复用出现位置。
|
||
assert_eq!(
|
||
session
|
||
.execute("log.mounted.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"4"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("log.unmounted.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
assert_eq!(
|
||
session.execute("saved.mounted", "app.main").unwrap().value,
|
||
"false"
|
||
);
|
||
// 新 Mount.page() 必须属于 second,防止业务对象相同却残留旧页面关系。
|
||
assert_eq!(
|
||
session
|
||
.execute("child.mounts().at(0).page()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
session.execute("second", "app.main").unwrap().value
|
||
);
|
||
// 再激活原页面也会创建新的两个出现位置,总挂载数应由 4 变为 6。
|
||
session.execute("page.activate()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("log.mounted.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"6"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// onMount 向容器 append 新文字,最终文档应包含该结构变化,验证回调后继续收敛。
|
||
/// 另一个 onMount 传入错误 setText 参数,错误应停止共享运行时而非忽略生命周期失败。
|
||
fn lifecycle_hook_can_edit_container_without_session_lock_and_failure_stops_domain() {
|
||
// GrowingColumn 的 onMount 修改自身子项,验证生命周期回调允许正常结构编辑并继续收敛。
|
||
let source = r#"class GrowingColumn: Column {
|
||
function onMount(self: GrowingColumn, mount: Mount): Unit { self.append(TextBlock("from hook")) }
|
||
}
|
||
root = GrowingColumn(0, 0)
|
||
page = Page()
|
||
page.render(root)"#;
|
||
// 从加载返回的首帧就应看到新增文字,不能等下次输入才补出钩子产生的内容。
|
||
let (_, document) = ReplSession::load(source, "app.main", 53, 1).unwrap();
|
||
// 比较真实文字命令,确认 append 已进入最终布局/绘制结果。
|
||
assert!(document.frame.commands.iter().any(
|
||
|command| matches!(command, RenderCommand::Text { value, .. } if value == "from hook")
|
||
));
|
||
// 另建独立会话测试错误钩子,避免前一合法结构变更影响失败路径。
|
||
let mut session = ReplSession::new(54).unwrap();
|
||
// BrokenText 钩子给 setText 传数字,故障必须向会话传播,不能静默忽略生命周期错误。
|
||
let source = r#"class BrokenText: TextBlock {
|
||
function onMount(self: BrokenText, mount: Mount): Unit { self.setText(1) }
|
||
}
|
||
page = Page()
|
||
page.render(BrokenText("x"))"#;
|
||
assert!(session.execute(source, "app.main").is_err());
|
||
assert!(session.is_faulted());
|
||
}
|
||
|
||
/// 在标准事件示例后附加 extra 语句,创建独立会话和初始完整文档。
|
||
/// 各路由测试只需描述额外监听器,即可共享相同父子树、计数器与默认事件配置。
|
||
fn event_session(extra: &str) -> (ReplSession, RenderDocument) {
|
||
// 下方事件路由测试调用本助手;追加语句可调整监听器,不必复制整份树和业务模型。
|
||
let source = format!(
|
||
"{}\n{}",
|
||
include_str!("../../../examples/events.endor"),
|
||
extra
|
||
);
|
||
// 正常加载并收敛初始页面,向调用方返回会话和可用于事件版本校验的完整文档。
|
||
ReplSession::load(&source, "app.main", 41, 1).unwrap()
|
||
}
|
||
|
||
/// 按全局 button 的业务身份找给定文档中的可交互 Mount,构造无坐标的运行时 click。
|
||
/// 事件使用该文档的会话与布局版本,返回派发后的完整文档,避免用过期目标绕过真实输入检查。
|
||
fn click_document(session: &mut ReplSession, document: &RenderDocument) -> RenderDocument {
|
||
// 事件测试助手从当前会话的名字表取得 button,保持业务身份与输入目标一致。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 缺失或非对象说明夹具不满足约定,应直接失败而非挑选任意可交互节点。
|
||
let Value::Object(button) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("button".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Button")
|
||
};
|
||
// 同时按 Block ID 与 interactive 筛选,确保测试输入指向当前文档中的有效按钮出现位置。
|
||
let mount = document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.interactive && mount.block_id == button.to_string())
|
||
.unwrap();
|
||
// 携带传入文档的 session/layout_version 派发 click;要求有发布结果供调用方继续验证。
|
||
session
|
||
.dispatch(&crate::events::Input {
|
||
window: crate::window::MAIN_WINDOW,
|
||
session: document.frame.session,
|
||
layout_version: document.frame.layout_version,
|
||
mount: mount.id,
|
||
kind: "click".into(),
|
||
point: None,
|
||
delta: None,
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap()
|
||
.document
|
||
.unwrap()
|
||
}
|
||
|
||
#[test]
|
||
/// 通过原生输入路径触发一次 click,检查捕获、目标方法、目标监听器、冒泡依次记为 1/2/3/4。
|
||
/// 派发结束后的模型保留最后阶段信息,订阅令牌仍连接。
|
||
fn managed_event_route_orders_capture_method_listeners_and_bubble() {
|
||
// 基线事件夹具保留捕获、目标方法、目标监听器和冒泡四个阶段,不追加额外监听器。
|
||
let (mut session, initial) = event_session("");
|
||
// 通过模拟原生 click 进入宿主路由,并验证回调产生的新文档结构有效。
|
||
click_document(&mut session, &initial).validate().unwrap();
|
||
// 逐阶段核对同一个递增序列,既检查执行次数也检查阶段之间的先后关系。
|
||
for (field, expected) in [
|
||
("captureStep", "1"),
|
||
("methodStep", "2"),
|
||
("targetStep", "3"),
|
||
("bubbleStep", "4"),
|
||
] {
|
||
// 按字段名读取源模型;每个字段由对应阶段回调写入,不能靠最终总数推断顺序。
|
||
assert_eq!(
|
||
session
|
||
.execute(&format!("model.{field}"), "app.main")
|
||
.unwrap()
|
||
.value,
|
||
expected
|
||
);
|
||
}
|
||
// 模型保存的最后阶段应是 bubble;监听令牌仍连接,正常派发不自动退订。
|
||
assert_eq!(
|
||
session.execute("model.phase", "app.main").unwrap().value,
|
||
"\"bubble\""
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("target.isConnected()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 第一场景在捕获中断开尚未调用的目标监听器,必须跳过它但继续冒泡。
|
||
/// 第二场景在目标捕获中 stopPropagation,当前目标组仍执行,祖先冒泡被阻止。
|
||
fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_group() {
|
||
// 在祖先捕获中追加断开 target 的回调;断开发生在目标监听器尚未轮到执行之前。
|
||
let (mut session, initial) = event_session(
|
||
"function cancel(event: Event): Unit { target.disconnect() }\nconnection = root.on(\"click\", cancel, \"capture\")",
|
||
);
|
||
// 实际派发一次;捕获与目标方法仍执行,目标监听器跳过后冒泡成为第 3 步。
|
||
click_document(&mut session, &initial);
|
||
// 检查目标没有写入序号、冒泡继续执行且令牌已断开,三项共同界定同次派发的退订语义。
|
||
assert_eq!(
|
||
session
|
||
.execute("model.targetStep", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("model.bubbleStep", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"3"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("target.isConnected()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"false"
|
||
);
|
||
// 另建会话,在目标捕获组调用 stopPropagation;不沿用前一场景的已断开监听器。
|
||
let (mut session, initial) = event_session(
|
||
"function stop(event: Event): Unit { event.stopPropagation() }\nconnection = button.on(\"click\", stop, \"capture\")",
|
||
);
|
||
// 重新派发后目标同组监听器仍为第 3 步,但祖先冒泡保持未执行的 0。
|
||
click_document(&mut session, &initial);
|
||
assert_eq!(
|
||
session
|
||
.execute("model.targetStep", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"3"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("model.bubbleStep", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 目标捕获使用 stopImmediatePropagation,应连目标默认方法也跳过。
|
||
/// 代码派发 custom 事件可返回同一带 preventDefault 标记的 Event,结束后 phase 为 none,并允许再次派发。
|
||
fn immediate_stop_skips_target_method_and_code_dispatch_returns_cancelled_event() {
|
||
// 目标捕获阶段立即停止传播;祖先捕获已执行,但目标方法和后续监听器都应跳过。
|
||
let (mut session, initial) = event_session(
|
||
"function stop(event: Event): Unit { event.stopImmediatePropagation() }\nconnection = button.on(\"click\", stop, \"capture\")",
|
||
);
|
||
// 总计数只能为祖先捕获贡献的 1,区分立即停止与仅阻止祖先冒泡。
|
||
click_document(&mut session, &initial);
|
||
assert_eq!(
|
||
session
|
||
.execute("model.count.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"1"
|
||
);
|
||
// 单独测试源码 dispatch:custom 监听器调用 preventDefault,返回 Event 保留取消标记。
|
||
let (mut session, _) = event_session(
|
||
"function prevent(event: Event): Unit { event.preventDefault() }\nconnection = button.on(\"custom\", prevent)\nresult = button.dispatch(Event(\"custom\"))",
|
||
);
|
||
// 取消标记在返回对象上可见;派发退出后 Event.phase 应恢复 none。
|
||
assert_eq!(
|
||
session
|
||
.execute("result.isDefaultPrevented()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
assert_eq!(
|
||
session.execute("result.phase", "app.main").unwrap().value,
|
||
"\"none\""
|
||
);
|
||
// 已完成派发的 Event 可以重用;前次派发的活跃守卫必须已经解除。
|
||
session
|
||
.execute("button.dispatch(result)", "app.main")
|
||
.unwrap();
|
||
}
|
||
|
||
#[test]
|
||
/// 回调失败后断开故障监听器,再派发同一 Event,验证活跃派发守卫已释放。
|
||
/// 另外保留订阅 token 却删除隐藏目标,回收后目标弱升级失败且 token 断开,证明它不强持有目标。
|
||
fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() {
|
||
// 复用有效事件页面,追加一个会因 page.render 参数错误而失败的独立事件监听器。
|
||
let (mut session, _) = event_session("");
|
||
// 保留 failedEvent 句柄,错误后必须能重新派发同一对象以验证活跃守卫释放。
|
||
session.execute("function fail(event: Event): Unit { page.render(1) }\nfailing = button.on(\"failure\", fail)\nfailedEvent = Event(\"failure\")", "app.main").unwrap();
|
||
// 第一次派发必须失败,确保后面的重用检查覆盖异常展开路径。
|
||
assert!(
|
||
session
|
||
.execute("button.dispatch(failedEvent)", "app.main")
|
||
.is_err()
|
||
);
|
||
// 先移除故障回调,再用同一 Event 成功派发;若守卫泄漏,会误报重复派发。
|
||
// 本例未在首次失败后断言 phase/currentTarget 已复位;异常释放弱守卫不等于正常末尾 Finish 已执行。
|
||
session
|
||
.execute(
|
||
"failing.disconnect()\nbutton.dispatch(failedEvent)",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 创建未挂载的按钮并保留订阅 token,随后只删除业务全局强引用。
|
||
session
|
||
.execute(
|
||
"orphan = Button(\"unmounted\")\ntoken = orphan.on(\"click\", model.target)",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 取得名字表,便于准确删除 orphan,而保留 token 和其回调模型。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 取目标对象句柄以创建弱引用;Rust 局部句柄不应成为 VM 的持久业务根。
|
||
let Value::Object(orphan) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("orphan".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Block expected")
|
||
};
|
||
// 弱句柄用于确认目标确实回收,不只检查 token 对外显示已断开。
|
||
let weak = session.candidate.vm.downgrade(orphan).unwrap();
|
||
// 删除 orphan 的最后显式全局强引用;不删除 token,专门检查 token 不强持有目标。
|
||
session
|
||
.candidate
|
||
.vm
|
||
.dict_delete(globals, &Value::Text("orphan".into()))
|
||
.unwrap();
|
||
// 沿真实桥接根集合执行回收,测试弱订阅与 GC 的组合行为。
|
||
session
|
||
.bridge
|
||
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
|
||
.unwrap();
|
||
// 目标弱升级必须失败,且仍存活的 token 查询为未连接。
|
||
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
|
||
assert_eq!(
|
||
session
|
||
.execute("token.isConnected()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 加载原生视觉子类,把旧 label 引用保存到 alias 后替换为新的源实例。
|
||
/// 共享转发应让 alias 读写新字段、更新多个绘制位置,同时旧属性存储的弱句柄可回收。
|
||
fn source_native_subclasses_share_live_fields_and_release_replaced_storage() {
|
||
// 加载带源字段的原生视觉子类,初始文档与类型查询先验证基线合法。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/native-subclasses.endor"),
|
||
"app.main",
|
||
31,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 子类实例仍应满足 TextBlock/Page 原生名义类型,后续才测试替换与字段转发。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("isInstance(label, TextBlock)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("isInstance(page, Page)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
// 额外保留旧业务引用,替换 label 后 alias 应通过共享转发访问新的属性存储。
|
||
session.execute("alias = label", "app.main").unwrap();
|
||
// 取当前 label 对象,以其真实属性路径定位替换前的底层存储。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 要求目标是原夹具中的 Block 对象,而非 Console 展示文本或另建实例。
|
||
let Value::Object(label) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("label".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("Block expected")
|
||
};
|
||
// 读取属性转发链,末尾存储是本用例要检查是否被替换并回收的对象。
|
||
let path = session
|
||
.candidate
|
||
.vm
|
||
.object_store()
|
||
.attribute_path(label)
|
||
.unwrap();
|
||
// 只保存旧属性存储的弱句柄,避免测试本身强持有旧存储而阻止回收。
|
||
let previous = session
|
||
.candidate
|
||
.vm
|
||
.downgrade(*path.last().unwrap())
|
||
.unwrap();
|
||
// 用同名源实例替换 label,首次新文档应已显示新字段内容。
|
||
let updated = session
|
||
.execute(
|
||
r##"label = CustomStatus("replacement", 2, "#242422", "#237A45", "new field")"##,
|
||
"app.main",
|
||
)
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 验证替换已经进入绘制结果,再检查旧存储回收与 alias 读写转发。
|
||
assert!(updated.frame.commands.iter().any(
|
||
|command| matches!(command, RenderCommand::Text { value, .. } if value == "new field")
|
||
));
|
||
// 回收不再从共享业务身份可达的旧存储;alias 不能让过时属性存储继续存活。
|
||
session
|
||
.bridge
|
||
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
|
||
.unwrap();
|
||
// 旧存储应消失,但 alias.message 必须仍读取替换后的 new field。
|
||
assert!(session.candidate.vm.upgrade(previous).unwrap().is_none());
|
||
assert_eq!(
|
||
session.execute("alias.message", "app.main").unwrap().value,
|
||
"\"new field\""
|
||
);
|
||
// 点击原按钮触发业务更新,多个文字出现应共同看到替换后的字段值。
|
||
let updated = session
|
||
.execute("button.click()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert_eq!(updated.frame.commands.iter().filter(|command| matches!(command, RenderCommand::Text { value, .. } if value == "new field")).count(), 3);
|
||
// 最后从旧 alias 写入字段,新文档也应反映 changed field,覆盖双向转发。
|
||
let updated = session
|
||
.execute("alias.message = \"changed field\"", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(updated.frame.commands.iter().any(
|
||
|command| matches!(command, RenderCommand::Text { value, .. } if value == "changed field")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
/// 完整源码加载后执行 Console 类型查询,再用原生 click 调用原程序中的源回调。
|
||
/// 两个共享文字都应变为 10 且 VM 健康,覆盖混合入口中源 ID/编译单元身份不冲突的行为。
|
||
fn loaded_program_console_and_native_events_share_unique_source_ids() {
|
||
// 先加载完整源码编译单元,保留其中源回调;随后追加 Console 单元并触发原生事件。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/managed-types.endor"),
|
||
"app.main",
|
||
1,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 在已有程序上执行 Console 类型查询,验证混合输入可以继续引用原对象和类型。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("isInstance(page, Block)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
// 从初始帧找可交互按钮,用原帧版本构造原生 click,回调仍来自最初加载的源码。
|
||
let mount = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.interactive)
|
||
.unwrap();
|
||
// 固定会话 ID 与加载一致,避免过期输入拒绝掩盖源 ID/编译单元冲突。
|
||
let event = crate::events::Input {
|
||
window: crate::window::MAIN_WINDOW,
|
||
session: 1,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: mount.id,
|
||
kind: "click".into(),
|
||
point: None,
|
||
delta: None,
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
};
|
||
// 派发原生输入后要求返回新文档并通过结构验证。
|
||
let updated = session.dispatch(&event).unwrap().document.unwrap();
|
||
updated.validate().unwrap();
|
||
// 两处共享文字都应变为 10,同时 VM 保持健康,证明跨入口回调没有落到错误源码单元。
|
||
assert_eq!(
|
||
updated
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.filter(|command| matches!(command,
|
||
RenderCommand::Text { value, .. } if value == "10"
|
||
))
|
||
.count(),
|
||
2
|
||
);
|
||
assert!(!session.is_faulted());
|
||
}
|
||
|
||
#[test]
|
||
/// 向被裁剪的子项中心发送 click,虽在其原始矩形内也不得触发计数。
|
||
/// 随后滚动同一 ScrollBlock 的一个出现位置、scrollIntoView 并扩大容器,分别检查独立偏移、可见性及范围回落到零。
|
||
fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() {
|
||
// 加载两个窗口共享 root/scroller 的实际示例;主窗口 frame 用来构造坐标输入。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/scroll.endor"),
|
||
"app.main",
|
||
1,
|
||
240,
|
||
)
|
||
.unwrap();
|
||
// 从当前 VM 全局表取业务按钮,后续通过对象身份定位它的主窗口出现位置。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// middle 位于滚动内容的中段,初始未滚动时中心应被容器裁剪。
|
||
let Value::Object(middle) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("middle".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("expected Block");
|
||
};
|
||
// 只在主窗口发布帧中找 middle 的 Mount,避免选到另一个窗口的同一业务对象。
|
||
let target = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|m| m.block_id == middle.to_string())
|
||
.unwrap();
|
||
// 构造原始矩形的中心点,刻意让几何包含但可见裁剪区域不包含这个位置。
|
||
let point = [
|
||
target.x + target.width / 2.0,
|
||
target.y + target.height / 2.0,
|
||
];
|
||
// 先验证夹具确实产生裁剪外目标;contains 同时考虑有效可见区域,不能只测原始宽高。
|
||
assert!(!target.contains(crate::geometry::Point::new(point[0], point[1])));
|
||
// 携带有效会话和布局版本,按下/释放都在该中心点;唯一拒绝原因应为裁剪命中检查。
|
||
session
|
||
.dispatch(&crate::events::Input {
|
||
window: 1,
|
||
session: 240,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: target.id,
|
||
kind: "click".into(),
|
||
point: Some(point),
|
||
press_point: Some(point),
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
delta: None,
|
||
})
|
||
.unwrap();
|
||
// 业务计数仍为零,证明即使客户端直接提供该 Mount,也不能绕过裁剪触发 click。
|
||
assert_eq!(
|
||
session.execute("count.value", "app.main").unwrap().value,
|
||
"0"
|
||
);
|
||
// 在第一个 Mount 连续 scrollTo/scrollBy,验证同次源码输入中相对滚动累加到 150。
|
||
session.execute("saved = scroller.mounts().at(0)\nsaved.scrollTo(0,100)\nsaved.scrollBy(0,20)\nsaved.scrollBy(0,30)","app.main").unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("saved.scrollPosition().y", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"150"
|
||
);
|
||
// 第二个出现位置仍为零,偏移属于 Mount,不应共享到同一 ScrollBlock 的另一窗口。
|
||
assert_eq!(
|
||
session
|
||
.execute("scroller.mounts().at(1).scrollPosition().y", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
// 请求把底部按钮的第一个出现位置滚入视野,后面读取的是更新后的文档。
|
||
session
|
||
.execute("last.mounts().at(0).scrollIntoView()", "app.main")
|
||
.unwrap();
|
||
// 仍通过业务身份查找 last,避免用旧文档的过期几何判断 scrollIntoView。
|
||
let Value::Object(last) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("last".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("expected Block");
|
||
};
|
||
// 取会话最新发布的主窗口帧,检查滚动后的可见性和逐命令裁剪数据。
|
||
let frame = &session.documents[&crate::window::MAIN_WINDOW].frame;
|
||
// 重新定位底部按钮的最新出现几何,不能沿用滚动前的 target。
|
||
let target = frame
|
||
.mounts
|
||
.iter()
|
||
.find(|m| m.block_id == last.to_string())
|
||
.unwrap();
|
||
// 按钮中心现在应在有效可见区域内,证明 scrollIntoView 真正改变投影而非只改状态字段。
|
||
assert!(target.contains(crate::geometry::Point::new(
|
||
target.x + target.width / 2.0,
|
||
target.y + target.height / 2.0
|
||
)));
|
||
// 每条命令都应有对应裁剪项,包含源 paint 生成的命令。
|
||
assert_eq!(frame.command_clips.len(), frame.commands.len());
|
||
// 增大滚动容器到能容纳全部内容;已有偏移必须随新范围夹回零。
|
||
session
|
||
.execute("scroller.height = 1000", "app.main")
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("saved.scrollPosition().y", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
// 同时检查最大滚动量归零,避免偏移偶然为零但范围仍停留在旧高度。
|
||
assert_eq!(
|
||
session
|
||
.execute("saved.scrollPosition().maxY", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 为已有 Mount 建立长延时任务,再开始会挂起的 resize 布局并替换视口目标。
|
||
/// 取消应恢复旧几何/挂载身份、清除临时 layout,最终重新收敛且原任务仍 pending,避免把放弃的准备当成卸载。
|
||
fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() {
|
||
// 重复 40 个同一 item 引用,使源布局有足够工作量进入可暂停路径。
|
||
let children = std::iter::repeat_n("item", 40)
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
// Wide.measure 提供固定尺寸,夹具让挂起发生在布局而非业务程序构造期间。
|
||
let source = format!(
|
||
"class Wide: Row {{\n function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult {{ return MeasureResult(340, 60) }}\n}}\nitem = RectBlock(2, 20)\npage = Page()\npage.render(Wide(0, 0, {children}))"
|
||
);
|
||
// 先完整发布初始页面,后面的取消需要恢复到这份已生效的 Mount 上下文。
|
||
let (mut session, _) = ReplSession::load(&source, "app.main", 272, 1).unwrap();
|
||
// 保存已挂载 Mount,并以它为 owner 注册长延时任务;任务状态用来检测是否误当卸载。
|
||
session.execute("saved = item.mounts().at(0)\nreceived = ui.state(0)\ntoken = ui.after(saved, \"keep\", 60000, received.increment)", "app.main").unwrap();
|
||
// 读取源 saved 引用,取消后直接检查其原生 Mount payload。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 要求保留的是实际 Mount 对象;不使用重新查询出的新引用掩盖身份丢失。
|
||
let Value::Object(saved) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("saved".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("saved Mount");
|
||
};
|
||
// 提交第一个视口目标,启动一轮尚未完成的 resize 布局。
|
||
let first = std::collections::BTreeMap::from([(
|
||
crate::window::MAIN_WINDOW,
|
||
crate::layout::Size {
|
||
width: 800.0,
|
||
height: 600.0,
|
||
},
|
||
)]);
|
||
// 有界推进到 VM 真正挂起,确保接下来的新尺寸会取消活跃续体。
|
||
for _ in 0..20 {
|
||
session.resize_windows_incremental(&first, false).unwrap();
|
||
// 观察到真实暂停调用即停止推进,避免把旧布局完成后再 resize 算作取消测试。
|
||
if session.candidate.vm.has_suspended_call() {
|
||
break;
|
||
}
|
||
}
|
||
// 若始终未挂起则测试没有覆盖取消前提,必须失败。
|
||
assert!(session.candidate.vm.has_suspended_call());
|
||
// 以不同宽高替换未完成目标,新布局应以最新视口重新开始。
|
||
let latest = std::collections::BTreeMap::from([(
|
||
crate::window::MAIN_WINDOW,
|
||
crate::layout::Size {
|
||
width: 900.0,
|
||
height: 650.0,
|
||
},
|
||
)]);
|
||
// 首次重定向还不发布新帧,同时必须清除旧 VM 暂停调用。
|
||
assert!(
|
||
session
|
||
.resize_windows_incremental(&latest, false)
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
assert!(!session.candidate.vm.has_suspended_call());
|
||
// 直接查看保存句柄的 payload:应恢复已挂载标志、清除临时 layout 并保留旧宽度。
|
||
let mount = session
|
||
.candidate
|
||
.vm
|
||
.payload::<crate::mount::Mount>(saved, "Mount")
|
||
.unwrap();
|
||
// 取消准备工作不等于卸载;恢复状态后才继续完整收敛新布局。
|
||
assert!(mount.mounted);
|
||
assert!(mount.layout.is_none());
|
||
assert_eq!(mount.geometry.width, 2.0);
|
||
// 完成最新视口下的发布,再确认会话健康、原 owner 任务仍 pending,且只取消过一次。
|
||
assert!(session.resize_windows(&latest).unwrap().is_some());
|
||
assert!(!session.is_faulted());
|
||
assert_eq!(
|
||
session.execute("token.status()", "app.main").unwrap().value,
|
||
"\"pending\""
|
||
);
|
||
assert_eq!(source_measure_stat(&mut session, "layoutCancels"), 1);
|
||
}
|
||
|
||
#[test]
|
||
/// 增量加载含 80 个出现位置的新候选,在初始布局尚未发布时替换视口目标。
|
||
/// 候选文档在完成前始终为空;重新收敛后构建计数仍为 1、挂载计数恰好 80,证明取消只重做布局。
|
||
fn candidate_initial_layout_retargets_without_reexecuting_program_or_hooks() {
|
||
// 80 个共享出现位置使首次候选布局跨片执行,为发布前的视口替换提供可观察窗口。
|
||
let children = std::iter::repeat_n("item", 80)
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
// built 仅在程序顶层递增,mounted 在每个 onMount 递增,分别检测源码和生命周期是否重放。
|
||
let source = format!(
|
||
"class Watched: RectBlock {{\n count: Object\n function onMount(self: Watched, mount: Mount): Unit {{ self.count.increment() }}\n}}\nbuilt = ui.state(0)\nbuilt.increment()\nmounted = ui.state(0)\nitem = Watched(2,20,mounted)\npage = Page()\npage.render(Row(0,0,{children}))"
|
||
);
|
||
// 使用增量加载入口,允许程序已执行但首个完整文档尚未生成。
|
||
let (mut candidate, document) =
|
||
ReplSession::load_incremental(&source, "app.main", 277, 1, None).unwrap();
|
||
// 首次返回应仍待刷新,候选文档表也必须为空,不能发布初始半帧。
|
||
assert!(document.is_none());
|
||
assert!(candidate.has_pending_refresh());
|
||
assert!(candidate.documents.is_empty());
|
||
// 空更新表沿用现有视口,只推进候选刷新到真实布局暂停点。
|
||
let initial = std::collections::BTreeMap::new();
|
||
// 限制推进次数;暂停后再次检查文档仍为空,确认没有中途发布。
|
||
for _ in 0..20 {
|
||
candidate
|
||
.resize_windows_incremental(&initial, false)
|
||
.unwrap();
|
||
// 以 VM 的暂停调用作为取消前提,不能仅凭 has_pending_refresh 判断当前阶段。
|
||
if candidate.candidate.vm.has_suspended_call() {
|
||
break;
|
||
}
|
||
}
|
||
assert!(candidate.candidate.vm.has_suspended_call());
|
||
assert!(candidate.documents.is_empty());
|
||
// 替换候选尚未发布的布局目标;仍沿用已经执行好的业务对象和钩子进度。
|
||
let latest = std::collections::BTreeMap::from([(
|
||
crate::window::MAIN_WINDOW,
|
||
crate::layout::Size {
|
||
width: 900.0,
|
||
height: 650.0,
|
||
},
|
||
)]);
|
||
// 先触发旧续体取消,再通过同步包装推进到最新目标下的完整首帧。
|
||
candidate
|
||
.resize_windows_incremental(&latest, false)
|
||
.unwrap();
|
||
assert!(!candidate.candidate.vm.has_suspended_call());
|
||
// 最后一个宽 2 的子项位于 79×2=158;正确几何之外还要核对程序仅构建一次、挂载恰好 80 次。
|
||
let settled = candidate.resize_windows(&latest).unwrap().unwrap();
|
||
assert_eq!(settled.frame.mounts.last().unwrap().x, 158.0);
|
||
assert_eq!(
|
||
candidate.execute("built.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
assert_eq!(
|
||
candidate
|
||
.execute("mounted.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"80"
|
||
);
|
||
// 取消统计应恰好一次,证明改变视口确实经过候选布局重定向路径。
|
||
assert_eq!(source_measure_stat(&mut candidate, "layoutCancels"), 1);
|
||
}
|
||
|
||
#[test]
|
||
/// 一次输入递增计数、改宽度并返回值,源执行结束后布局继续 Pending。
|
||
/// 此时禁止新 mutation/命令投影,保留旧文档;换视口后最终几何更新,但返回值和递增都只发生一次。
|
||
fn incremental_input_retains_value_and_never_replays_source_while_retargeting() {
|
||
// 构造 80 个共享 item 的 Row,确保一次宽度更新需要分片布局。
|
||
let children = std::iter::repeat_n("item", 80)
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
// count 记录顶层输入执行次数;item.width 更新后影响全部出现位置。
|
||
let source = format!(
|
||
"count = ui.state(0)\nitem = RectBlock(2,20)\npage = Page()\npage.render(Row(0,0,{children}))"
|
||
);
|
||
// 先完整发布旧页面,后续 Pending 期间必须继续保留其版本。
|
||
let (mut session, initial) = ReplSession::load(&source, "app.main", 275, 1).unwrap();
|
||
// 保持现有视口,先单独提交业务输入并等待布局刷新。
|
||
let first = std::collections::BTreeMap::new();
|
||
// 同一条输入递增、改宽并返回计数;后续布局续体不得重新执行这三步。
|
||
let result = session
|
||
.execute_incremental(
|
||
"count.increment()\nitem.width = 3\ncount.value",
|
||
"app.main",
|
||
&first,
|
||
)
|
||
.unwrap();
|
||
// 业务结果此时已经是 1,但没有新文档;待刷新时不能消费窗口命令或提交另一条修改。
|
||
assert_eq!(result.value, "1");
|
||
assert!(result.document.is_none());
|
||
assert!(session.has_pending_refresh());
|
||
assert!(session.take_window_commands().is_err());
|
||
assert!(session.execute("count.increment()", "app.main").is_err());
|
||
// 有界推进到真实暂停,再发新视口触发取消,覆盖输入执行完而布局未完成的阶段。
|
||
for _ in 0..20 {
|
||
session.resize_windows_incremental(&first, false).unwrap();
|
||
// 只在 VM 实际挂起时结束推进,保证换视口取消的是活跃工作。
|
||
if session.candidate.vm.has_suspended_call() {
|
||
break;
|
||
}
|
||
}
|
||
assert!(session.candidate.vm.has_suspended_call());
|
||
// 旧版本在未完成期间仍对外可见;输入结果和对外文档分别管理。
|
||
assert_eq!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version,
|
||
initial.frame.layout_version
|
||
);
|
||
// 最新目标替代旧布局目标,输入已执行的业务写入和返回值都必须保留。
|
||
let latest = std::collections::BTreeMap::from([(
|
||
crate::window::MAIN_WINDOW,
|
||
crate::layout::Size {
|
||
width: 900.0,
|
||
height: 650.0,
|
||
},
|
||
)]);
|
||
// 先触发重定向,再同步收敛至新帧,不能重新调用 execute_incremental。
|
||
session.resize_windows_incremental(&latest, false).unwrap();
|
||
// 宽度已变为 3,最后子项 x=79×3=237;返回值及当前 count 都仍为 1,排除源码重放。
|
||
let settled = session.resize_windows(&latest).unwrap().unwrap();
|
||
assert_eq!(settled.frame.mounts.last().unwrap().x, 237.0);
|
||
assert_eq!(result.value, "1");
|
||
assert_eq!(
|
||
session.execute("count.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
assert_eq!(source_measure_stat(&mut session, "layoutCancels"), 1);
|
||
}
|
||
|
||
#[test]
|
||
/// 点击回调先改变业务计数,再通过多次增量刷新完成大树布局。
|
||
/// 挂起期间旧文档版本不变,最终计数为 1 且 refreshYields 大于零,避免分片时重放 click。
|
||
fn incremental_event_runs_callback_once_before_publishing_its_layout() {
|
||
// 重复 80 个 item 增加刷新工作量,按钮回调只负责递增 count。
|
||
let children = std::iter::repeat_n("item", 80)
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
// 计数通过 ui.textOf 可视化,点击后必须产生真实布局/绘制刷新而非仅后台字段更新。
|
||
let source = format!(
|
||
"count = ui.state(0)\nitem = RectBlock(2,20)\nbutton = Button(\"increment\")\nbutton.onClick(count.increment)\npage = Page()\npage.render(Column(0,0,button,ui.textOf(count,\"value\"),Row(0,0,{children})))"
|
||
);
|
||
// 先完整加载基线文档,再从该文档取有效事件目标。
|
||
let (mut session, initial) = ReplSession::load(&source, "app.main", 276, 1).unwrap();
|
||
// 选择唯一可交互按钮,保证输入通过正常宿主事件路径调用计数回调。
|
||
let mount = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.interactive)
|
||
.unwrap();
|
||
// 事件身份与初始文档一致;无坐标的直接目标 click 专注测试回调与增量刷新衔接。
|
||
let input = crate::events::Input {
|
||
window: crate::window::MAIN_WINDOW,
|
||
session: session.id,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: mount.id,
|
||
kind: "click".into(),
|
||
point: None,
|
||
press_point: None,
|
||
delta: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
};
|
||
// 不额外改变窗口尺寸,只推进该次事件已启动的刷新。
|
||
let viewports = std::collections::BTreeMap::new();
|
||
// 回调已被调用,但第一步增量派发尚未完成新文档,应留下 pending 刷新。
|
||
let result = session.dispatch_incremental(&input, &viewports).unwrap();
|
||
assert!(result.document.is_none());
|
||
assert!(session.has_pending_refresh());
|
||
// 对外继续展示旧版本,禁止把单个阶段完成误当作可发布帧。
|
||
assert_eq!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version,
|
||
initial.frame.layout_version
|
||
);
|
||
// 记录是否真的得到最终文档,防止循环达到上限后仍以部分状态通过。
|
||
let mut complete = false;
|
||
// 反复推进同一刷新;不得重新 dispatch input,否则会重复执行业务回调。
|
||
for _ in 0..1000 {
|
||
// 只有返回 Some(document) 才标记完成,None 仍表示分片尚未发布。
|
||
if session
|
||
.resize_windows_incremental(&viewports, false)
|
||
.unwrap()
|
||
.is_some()
|
||
{
|
||
complete = true;
|
||
break;
|
||
}
|
||
}
|
||
// 必须完成并清除 pending;最后计数仍为 1,统计中应实际观察到过让出。
|
||
assert!(complete);
|
||
assert!(!session.has_pending_refresh());
|
||
assert_eq!(
|
||
session.execute("count.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
// refreshYields 是当前会话累计计数,>0 证明曾让出,不给出这一次事件单独贡献的切片次数。
|
||
assert!(source_measure_stat(&mut session, "refreshYields") > 0);
|
||
}
|
||
|
||
#[test]
|
||
/// 只用原生布局的大树也应进入可恢复路径。
|
||
/// Pending 期间保留旧帧,换视口取消后重算仍产出最后子项 x=158/width=2,并记录切片与取消次数。
|
||
fn large_intrinsic_layout_yields_and_retargets_without_geometry_loss() {
|
||
// 用 80 次共享引用放大原生 Row 的布局,测试不依赖源方法覆盖也能分片。
|
||
let children = std::iter::repeat_n("item", 80)
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
// 业务对象只有一个 item,所有出现各占宽 2,最后一个的预期 x 为 158。
|
||
let source = format!(
|
||
"item = RectBlock(2,20)\nrow = Row(0,0,{children})\npage = Page()\npage.render(row)"
|
||
);
|
||
// 完成基线帧,保存版本并先确认原始几何正确,后面才测试取消恢复。
|
||
let (mut session, initial) = ReplSession::load(&source, "app.main", 273, 1).unwrap();
|
||
// 记录已发布版本,Pending 时不能提前更换对外文档。
|
||
let old_version = initial.frame.layout_version;
|
||
assert_eq!(initial.frame.mounts.last().unwrap().x, 158.0);
|
||
// 第一次视口变化启动原生布局增量刷新。
|
||
let first = std::collections::BTreeMap::from([(
|
||
crate::window::MAIN_WINDOW,
|
||
crate::layout::Size {
|
||
width: 800.0,
|
||
height: 600.0,
|
||
},
|
||
)]);
|
||
// 推进直到 VM 真正暂停;若只有刷新标记而无暂停,就没有覆盖续体取消。
|
||
for _ in 0..20 {
|
||
session.resize_windows_incremental(&first, false).unwrap();
|
||
// 暂停一旦出现就停止本轮推进,保留未完成工作供新视口替换。
|
||
if session.candidate.vm.has_suspended_call() {
|
||
break;
|
||
}
|
||
}
|
||
assert!(session.candidate.vm.has_suspended_call());
|
||
// 旧帧版本仍可见,说明原生布局的中间几何未提前发布。
|
||
assert_eq!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version,
|
||
old_version
|
||
);
|
||
// 用新尺寸取消旧布局,必须保留业务对象及完整出现位置集合。
|
||
let latest = std::collections::BTreeMap::from([(
|
||
crate::window::MAIN_WINDOW,
|
||
crate::layout::Size {
|
||
width: 900.0,
|
||
height: 650.0,
|
||
},
|
||
)]);
|
||
// 先触发取消并检查旧暂停已清除,再继续完成新布局。
|
||
session.resize_windows_incremental(&latest, false).unwrap();
|
||
assert!(!session.candidate.vm.has_suspended_call());
|
||
// 最终仍为 x=158、width=2;取消一次且布局跨多片,证明不是绕开分片直接复用旧帧。
|
||
let settled = session.resize_windows(&latest).unwrap().unwrap();
|
||
assert_eq!(settled.frame.mounts.last().unwrap().x, 158.0);
|
||
assert_eq!(settled.frame.mounts.last().unwrap().width, 2.0);
|
||
assert_eq!(source_measure_stat(&mut session, "layoutCancels"), 1);
|
||
assert!(source_measure_stat(&mut session, "layoutSlices") > 1);
|
||
}
|
||
|
||
#[test]
|
||
/// 两个立即就绪任务都递增同一状态,首次 poll 只交付第一个并启动刷新。
|
||
/// 刷新未完成时重复 poll 不交付第二个;第一帧完成后再 poll 才发布第二次递增。
|
||
fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() {
|
||
// 两个零延时任务依次递增同一可见状态,用来检查任务交付与上一回调刷新之间的串行边界。
|
||
let (mut session, initial) = ReplSession::load(
|
||
"state = ui.state(0)\npage = Page()\npage.render(ui.textOf(state,\"value\"))\na = ui.after(ui,\"a\",0,state.increment)\nb = ui.after(ui,\"b\",0,state.increment)",
|
||
"app.main", 274, 1,
|
||
).unwrap();
|
||
// 保存计数仍为零时的文档版本,第一回调刷新未完成前必须保持它。
|
||
let old_version = initial.frame.layout_version;
|
||
// 不改变视口,只驱动任务引起的刷新。
|
||
let viewports = std::collections::BTreeMap::new();
|
||
// 第一次 poll 交付 a 并启动刷新;尚无完成文档,has_pending_refresh 应为真。
|
||
assert!(
|
||
session
|
||
.poll_tasks_incremental(&viewports)
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
assert!(session.has_pending_refresh());
|
||
// 第二次 poll 不能越过未完成刷新再交付 b。
|
||
assert!(
|
||
session
|
||
.poll_tasks_incremental(&viewports)
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
assert_eq!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version,
|
||
old_version
|
||
);
|
||
// 完成第一回调对应的文档,核对版本前进、状态为 1、a 完成而 b 仍 pending。
|
||
let first = session.resize_windows(&viewports).unwrap().unwrap();
|
||
assert!(first.frame.layout_version > old_version);
|
||
assert_eq!(
|
||
session.execute("state.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
assert_eq!(
|
||
session.execute("a.status()", "app.main").unwrap().value,
|
||
"\"completed\""
|
||
);
|
||
assert_eq!(
|
||
session.execute("b.status()", "app.main").unwrap().value,
|
||
"\"pending\""
|
||
);
|
||
// 第一帧完成后才允许 b 交付;再收敛其刷新,应看到状态为 2 且 b 完成。
|
||
session.poll_tasks_incremental(&viewports).unwrap();
|
||
assert!(session.has_pending_refresh());
|
||
session.resize_windows(&viewports).unwrap();
|
||
assert_eq!(
|
||
session.execute("state.value", "app.main").unwrap().value,
|
||
"2"
|
||
);
|
||
assert_eq!(
|
||
session.execute("b.status()", "app.main").unwrap().value,
|
||
"\"completed\""
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 启动增量 resize 后尝试普通 mutation,应拒绝但不使 VM fault。
|
||
/// 逐次推进时每个未完成返回都保持旧文档版本,只有整体完成才一次发布新版本并清除 pending。
|
||
fn incremental_viewport_refresh_retains_published_documents_until_complete() {
|
||
// 源 flex 约束示例提供多阶段布局工作,用来检验视口刷新期间的发布原子性。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/flex-constraints.endor"),
|
||
"app.main",
|
||
271,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 从会话当前文档表保存完整帧版本,后续每个未完成切片都逐次核对。
|
||
let old_version = session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version;
|
||
// 窗口 1 的新逻辑尺寸是本轮刷新目标,循环中保持该目标不变。
|
||
let viewports = std::collections::BTreeMap::from([(
|
||
crate::window::MAIN_WINDOW,
|
||
crate::layout::Size {
|
||
width: 800.0,
|
||
height: 600.0,
|
||
},
|
||
)]);
|
||
// 第一步启动刷新并返回 None;此时普通修改应被拒绝,但不应让健康 VM fault。
|
||
assert!(
|
||
session
|
||
.resize_windows_incremental(&viewports, false)
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
assert!(session.has_pending_refresh());
|
||
assert!(session.execute("blocked = 1", "app.main").is_err());
|
||
assert!(!session.is_faulted());
|
||
// 记录实际发布完成,避免循环上限耗尽却误判成功。
|
||
let mut completed = false;
|
||
// 逐片推进直到发布;每次 None 后立即确认旧文档版本仍保持。
|
||
for _ in 0..1000 {
|
||
// 只在完整新文档返回时检查版本增加与 pending 清除,并停止推进。
|
||
if let Some(document) = session
|
||
.resize_windows_incremental(&viewports, false)
|
||
.unwrap()
|
||
{
|
||
assert!(document.frame.layout_version > old_version);
|
||
assert!(!session.has_pending_refresh());
|
||
completed = true;
|
||
break;
|
||
}
|
||
// 仍未完成的分支必须保留原文档版本,不能只在循环结束时检查最终结果。
|
||
assert_eq!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version,
|
||
old_version
|
||
);
|
||
}
|
||
// 确认循环获得了新帧且刷新统计确实发生过让出,覆盖增量而非同步完成路径。
|
||
assert!(completed);
|
||
assert!(source_measure_stat(&mut session, "refreshYields") > 0);
|
||
}
|
||
|
||
#[test]
|
||
/// 绕过会话同步包装,直接调用桥接 begin_layout_frame 观察首个 Pending。
|
||
/// 循环把工作所有权传回 resume,最终 Complete 才断言完整几何,且 VM 不再残留挂起调用。
|
||
fn bridge_layout_can_return_pending_before_committing_a_complete_frame() {
|
||
// 40 次共享出现加上源 Row.measure,让桥接布局入口返回可恢复工作。
|
||
let children = std::iter::repeat_n("item", 40)
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
// 源测量给定固定父尺寸,最终各子项应依次排列而不丢失任何出现。
|
||
let source = format!(
|
||
"class Wide: Row {{\n function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult {{ return MeasureResult(340, 60) }}\n}}\nitem = RectBlock(2, 20)\npage = Page()\npage.render(Wide(0, 0, {children}))"
|
||
);
|
||
// 先建立有效 VM/桥接状态,再绕过会话同步包装直接测试桥接分片接口。
|
||
let (mut session, _) = ReplSession::load(&source, "app.main", 270, 1).unwrap();
|
||
// 变更视口约束,防止布局完全沿用初始缓存。
|
||
session
|
||
.bridge
|
||
.set_viewport(
|
||
&mut session.candidate.vm,
|
||
Some(crate::layout::Size {
|
||
width: 500.0,
|
||
height: 300.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 只准备根节点,后续 begin_layout_frame 才启动可分片布局。
|
||
let root = session
|
||
.bridge
|
||
.prepare_frame(&mut session.candidate.vm)
|
||
.unwrap()
|
||
.unwrap();
|
||
// 保存工作枚举所有权,每次 Pending 都要把续体重新交回 resume。
|
||
let mut progress = session
|
||
.bridge
|
||
.begin_layout_frame(&mut session.candidate.vm, root)
|
||
.unwrap();
|
||
// 首次必须为 Pending 且 VM 有暂停调用,否则未测到桥接的恢复契约。
|
||
assert!(matches!(
|
||
progress,
|
||
crate::ui_bridge::LayoutProgress::Pending(_)
|
||
));
|
||
assert!(session.candidate.vm.has_suspended_call());
|
||
// 按返回状态继续推进,只有 Complete 分支允许读取最终几何。
|
||
// 此 loop 没有测试侧轮数/墙钟截止时间,终止依赖真实 VM/布局预算与完成契约;不能称作有界轮询助手。
|
||
loop {
|
||
// Pending 携带尚未完成的独占工作;Complete 则携带可检查的完整帧。
|
||
match progress {
|
||
// 将本片返回的工作移动回桥接,不复制也不重新创建布局,保留已完成片段。
|
||
crate::ui_bridge::LayoutProgress::Pending(pending) => {
|
||
progress = session
|
||
.bridge
|
||
.resume_layout_frame(&mut session.candidate.vm, *pending)
|
||
.unwrap();
|
||
}
|
||
// 最后子项位置为 39×2=78;完成后 VM 不得遗留暂停调用。
|
||
crate::ui_bridge::LayoutProgress::Complete(frame) => {
|
||
assert_eq!(frame.mounts.last().unwrap().x, 78.0);
|
||
assert!(!session.candidate.vm.has_suspended_call());
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 同一 item 在 Row 中重复 40 次,布局需跨多个 VM 切片。
|
||
/// 完整帧不能丢失出现位置,第 40 个的 x=78 且 Mount 数量覆盖容器与全部子项。
|
||
fn layout_slices_preserve_all_shared_occurrences() {
|
||
// 同一业务 item 重复出现 40 次;丢失某个 Mount 会直接改变数量或末项几何。
|
||
let children = std::iter::repeat_n("item", 40)
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
// 源父测量配合原生 Row 排列,跨多个 VM 布局切片验证出现位置保留。
|
||
let source = format!(
|
||
"class Wide: Row {{\n function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult {{ return MeasureResult(340, 60) }}\n}}\nitem = RectBlock(2, 20)\npage = Page()\npage.render(Wide(0, 0, {children}))"
|
||
);
|
||
// 同步包装会排空切片,测试读取最终文档并通过统计确认内部真实发生多片执行。
|
||
let (mut session, initial) = ReplSession::load(&source, "app.main", 269, 1).unwrap();
|
||
// 完整帧至少包含容器及 40 个子项,最后一个出现的源 bounds.x 必须为 78。
|
||
initial.validate().unwrap();
|
||
assert!(initial.frame.mounts.len() >= 41);
|
||
assert!(source_measure_stat(&mut session, "layoutSlices") > 1);
|
||
assert_eq!(
|
||
session
|
||
.execute("item.mounts().at(39).bounds().x", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"78"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 有限 Row 把带换行文字的 flex Column 缩窄,文字自然高度必须重新测量,不能沿用早期宽约束下的一行高度。
|
||
/// 将文字改短后高度回到 20,确认新求解不把先前分配冻结为永久尺寸。
|
||
fn nested_flex_refreshes_natural_height_when_cross_width_changes() {
|
||
// 父 Row 只提供 240 宽,内部 flex Column 的文字需在最终分配宽度下重新测量自然高度。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r#"
|
||
class Narrow: Row {
|
||
function measure(self: Narrow, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(240, 400) }
|
||
}
|
||
paragraph = TextBlock("Changing the parent's width must refresh the natural height of this wrapping paragraph. The first wide measurement must not freeze a single line of text. Changing the parent's width must refresh the natural height of this wrapping paragraph.")
|
||
paragraph.flexShrink = 1
|
||
column = Column(0, 0, paragraph)
|
||
column.flex = 1
|
||
page = Page()
|
||
page.render(Narrow(10, 0, column))
|
||
"#,
|
||
"app.main",
|
||
268,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先确认发布文档有效,再通过源 Mount API 读取真实排列后的文字高度。
|
||
initial.validate().unwrap();
|
||
// 把源返回的数值转为浮点,比较是否多于一行 20;不能冻结首次宽测量得到的单行高度。
|
||
let height: f32 = session
|
||
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse()
|
||
.unwrap();
|
||
// 长文字在窄约束下必须换行;随后改短以检验新一轮求解没有沿用旧的分配高度。
|
||
assert!(height > 20.0);
|
||
// 只改原 paragraph 的文字,下一帧应重测为单行 20,而非替换整棵布局树。
|
||
session
|
||
.execute("paragraph.setText(\"short\")", "app.main")
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"20"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 给两个 flex 子项显式 basis、不同增长权重和最大宽度,检查达到上限后剩余空间再分配。
|
||
/// 随后切换为收缩并让首项触及最小值,再恢复 auto basis,验证每次新求解都重新计算自然尺寸。
|
||
fn bounded_flex_redistributes_growth_and_scaled_shrink() {
|
||
// 父宽 340 扣两侧 padding 20、两个 gap 共 20、固定项 60,留给两个 flex 项的总宽为 240。
|
||
// 各 basis 为 100,增长权重 1:3;首项达到 max=105 后,多余空间应重新分配给第二项。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r#"
|
||
class Wide: Row {
|
||
function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(340, 60) }
|
||
}
|
||
first = RectBlock(20, 20)
|
||
second = RectBlock(40, 20)
|
||
fixed = RectBlock(60, 20)
|
||
first.flex = 1
|
||
second.flex = 3
|
||
first.flexBasis = 100
|
||
second.flexBasis = 100
|
||
first.flexMax = 105
|
||
row = Wide(10, 10, first, second, fixed)
|
||
page = Page()
|
||
page.render(row)
|
||
"#,
|
||
"app.main",
|
||
267,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 确认最终帧合法,以下查询必须读取出现位置几何,不能只读业务 width 自然值。
|
||
initial.validate().unwrap();
|
||
// 本测试闭包按业务名查询首个 Mount 的宽度,供三轮求解共用。
|
||
let width = |session: &mut ReplSession, name| {
|
||
session
|
||
.execute(&format!("{name}.mounts().at(0).bounds().width"), "app.main")
|
||
.unwrap()
|
||
.value
|
||
};
|
||
// 增长阶段分配为 105/135,总宽仍为 240;首项被上限冻结后剩余增长量转给第二项。
|
||
assert_eq!(width(&mut session, "first"), "105");
|
||
assert_eq!(width(&mut session, "second"), "135");
|
||
// 切换为无增长、等收缩权重且 basis 均为 200;首项 min=150 触底后,其余收缩应由第二项承担。
|
||
session.execute(
|
||
"first.flex = 0\nsecond.flex = 0\nfirst.flexMax = 300\nfirst.flexMin = 150\nfirst.flexBasis = 200\nsecond.flexBasis = 200\nfirst.flexShrink = 1\nsecond.flexShrink = 1",
|
||
"app.main",
|
||
).unwrap();
|
||
// 收缩结果为 150/90,固定项仍为 60;不能把固定项也拿来吸收缺口。
|
||
assert_eq!(width(&mut session, "first"), "150");
|
||
assert_eq!(width(&mut session, "second"), "90");
|
||
assert_eq!(width(&mut session, "fixed"), "60");
|
||
// 恢复 auto basis 并移除首项界限,求解应从自然宽 20/40 重新开始,不沿用前次分配。
|
||
session.execute(
|
||
"first.flexMin = 0\nfirst.flexMax = 1000000\nfirst.flexBasis = \"auto\"\nsecond.flexBasis = \"auto\"",
|
||
"app.main",
|
||
).unwrap();
|
||
assert_eq!(width(&mut session, "first"), "20");
|
||
assert_eq!(width(&mut session, "second"), "40");
|
||
}
|
||
|
||
#[test]
|
||
/// 同一段文字分别出现在两个 Row;首个 Row 的可伸缩兄弟权重改变会挤窄文字。
|
||
/// 只第一个出现位置的换行高度增加,第二个保持原高度,证明约束与自然尺寸缓存按 Mount 隔离。
|
||
fn native_flex_reflows_shared_text_per_occurrence() {
|
||
// 同一 paragraph 分别挂到两个 Row;只有首个 Row 的兄弟 filler 具有后续会改动的增长权重。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r#"
|
||
paragraph = TextBlock("Shared text has one object identity and separate layout constraints in each occurrence. Changing the sibling weight reflows only the affected occurrence.")
|
||
paragraph.flex = 1
|
||
filler = RectBlock(20, 20)
|
||
filler.flex = 1
|
||
first = Row(10, 10, paragraph, filler)
|
||
second = Row(10, 10, paragraph, RectBlock(300, 20))
|
||
page = Page()
|
||
page.render(Column(10, 10, first, second))
|
||
"#,
|
||
"app.main",
|
||
266,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先验证完整发布,再按 Mount 索引分别读取共享文字的两个出现高度。
|
||
initial.validate().unwrap();
|
||
// 闭包返回指定出现位置的最终高度,显式转 f32 供换行高度比较使用。
|
||
let height = |session: &mut ReplSession, index| -> f32 {
|
||
session
|
||
.execute(
|
||
&format!("paragraph.mounts().at({index}).bounds().height"),
|
||
"app.main",
|
||
)
|
||
.unwrap()
|
||
.value
|
||
.parse()
|
||
.unwrap()
|
||
};
|
||
// 首个出现的初始高度作为受影响位置的基线。
|
||
let before = height(&mut session, 0);
|
||
// 第二个出现单独保存基线,验证修改另一个父上下文不会污染其测量缓存。
|
||
let other = height(&mut session, 1);
|
||
// 先确认夹具确实让两个位置获得不同换行高度,而非恰好相同的约束。
|
||
assert!(before > other);
|
||
// 增大首个 Row 兄弟的权重以挤窄文字;只有第一个出现应增高,第二个必须保持基线。
|
||
session.execute("filler.flex = 3", "app.main").unwrap();
|
||
assert!(height(&mut session, 0) > before);
|
||
assert_eq!(height(&mut session, 1), other);
|
||
}
|
||
|
||
#[test]
|
||
/// 有限宽度 Row 先扣 padding、gap 与固定子项,再把 240 剩余宽度按 1:2 分配。
|
||
/// 修改权重为 3:1 后同一出现位置实时改为 180/60,验证分配结果反过来约束子测量。
|
||
fn weighted_flex_remeasures_children_and_updates_live_weights() {
|
||
// 父宽 340 扣除 padding 20、gap 20 和固定项 60,剩余 240 由 first/last 按增长权重分配。
|
||
let (mut session, initial) = ReplSession::load(r#"
|
||
class Wide: Row {
|
||
function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(340, 40) }
|
||
}
|
||
first = RectBlock(20, 20)
|
||
fixed = RectBlock(60, 20)
|
||
last = RectBlock(40, 20)
|
||
first.flex = 1
|
||
last.flex = 2
|
||
row = Wide(10, 10, first, fixed, last)
|
||
page = Page()
|
||
page.render(row)
|
||
"#, "app.main", 265, 1).unwrap();
|
||
// 有效首帧中权重 1:2 应分得 80/160,检查 Mount 宽度而不是自然业务 width。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("first.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"80"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("last.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"160"
|
||
);
|
||
// 在同一会话把权重改成 3:1,依赖变化应重新分配为 180/60,不能沿用首次分配缓存。
|
||
session
|
||
.execute("first.flex = 3\nlast.flex = 1", "app.main")
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("first.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"180"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("last.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"60"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 表驱动切换 start/center/end/space-between,逐项比较两个子项的 x。
|
||
/// 本例逐轮检查首项宽度仍为 20;第二项只检查 x,没有对其宽度作独立断言。
|
||
fn main_alignment_updates_live_geometry_without_changing_sizes() {
|
||
// 固定父宽 300、左右 padding 各 10,两子项自然宽 20/40、基础间距 10,留出可对齐的剩余空间。
|
||
let (mut session, initial) = ReplSession::load(r#"
|
||
class Wide: Row {
|
||
function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(300, 60) }
|
||
}
|
||
first = RectBlock(20, 20)
|
||
second = RectBlock(40, 40)
|
||
row = Wide(10, 10, first, second)
|
||
page = Page()
|
||
page.render(row)
|
||
"#, "app.main", 264, 1).unwrap();
|
||
// 确认基线可发布,后面只改 mainAlign 属性,不重新创建容器或子项。
|
||
initial.validate().unwrap();
|
||
// 枚举四种主轴对齐模式及两个预期 x:center 平分剩余空间,end 全放前部,space-between 放入项间。
|
||
for (mode, first, second) in [
|
||
("start", "10", "40"),
|
||
("center", "115", "145"),
|
||
("end", "220", "250"),
|
||
("space-between", "10", "250"),
|
||
] {
|
||
// 源字段写入触发实时布局,逐轮使用同一组对象检查对齐策略切换。
|
||
session
|
||
.execute(&format!("row.mainAlign = \"{mode}\""), "app.main")
|
||
.unwrap();
|
||
// 先核对首项和第二项位置,再检查首项自然宽仍为 20;对齐不应变成 flex 拉伸。
|
||
assert_eq!(
|
||
session
|
||
.execute("first.mounts().at(0).bounds().x", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
first
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("second.mounts().at(0).bounds().x", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
second
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("first.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"20"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 同一 RectBlock 分别放在 Row 和 Column,以两个父容器的 crossAlign 得到独立坐标。
|
||
/// alignSelf 显式值覆盖各父策略,恢复 auto 后再次继承,且修改一个父容器不影响另一个出现位置。
|
||
fn cross_alignment_is_live_and_specific_to_each_parent_occurrence() {
|
||
// 共享矩形分别出现在 Row 与 Column,交叉轴方向不同;父策略分别为 center 与 end。
|
||
let (mut session, initial) = ReplSession::load(r#"
|
||
class Tall: Row {
|
||
function measure(self: Tall, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) }
|
||
}
|
||
class Wide: Column {
|
||
function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) }
|
||
}
|
||
shared = RectBlock(20, 20)
|
||
row = Tall(10, 0, shared)
|
||
column = Wide(10, 0, shared)
|
||
row.crossAlign = "center"
|
||
column.crossAlign = "end"
|
||
page = Page()
|
||
page.render(Column(0, 0, row, column))
|
||
"#, "app.main", 294, 1).unwrap();
|
||
// 先验证初始帧,随后核对两个出现位置各自的坐标,包含第二容器在外层 Column 中的偏移。
|
||
initial.validate().unwrap();
|
||
// 首项在 Row 的 y 上居中,次项在 Column 的 x 上靠末端,不能把两个父上下文合并。
|
||
for (expression, expected) in [
|
||
("shared.mounts().at(0).bounds().x", "10"),
|
||
("shared.mounts().at(0).bounds().y", "40"),
|
||
("shared.mounts().at(1).bounds().x", "170"),
|
||
("shared.mounts().at(1).bounds().y", "110"),
|
||
] {
|
||
// 逐表达式读取源 Mount 几何,让位置比较覆盖实际发布布局而非静态属性。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
expected
|
||
);
|
||
}
|
||
// 业务对象上的显式 start 覆盖两个父容器的默认策略,两处交叉轴位置都应回到 padding 起点。
|
||
session
|
||
.execute("shared.alignSelf = \"start\"", "app.main")
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().at(0).bounds().y", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"10"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().at(1).bounds().x", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"10"
|
||
);
|
||
// 恢复 auto 后重新继承各自父策略;只把 Row 改为 end,Column 的另一出现应仍保持原来的 end。
|
||
session
|
||
.execute(
|
||
"shared.alignSelf = \"auto\"\nrow.crossAlign = \"end\"",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().at(0).bounds().y", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"70"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().at(1).bounds().x", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"170"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 源 arrange 主动把第一个子项 y 改为 7,后续原生 crossAlign 不能覆盖源给定位置。
|
||
/// 另一子项比父交叉轴更宽时按起点溢出,center 不应产生负起点。
|
||
fn cross_alignment_preserves_source_placements_and_starts_overflow() {
|
||
// 一个源 Row.arrange 显式指定 y=7,另一个源 Column.measure 只分配宽 10;分别覆盖源位置优先及自然子项溢出。
|
||
let (mut session, initial) = ReplSession::load(r#"
|
||
class Placed: Row {
|
||
function arrange(self: Placed, mount: Mount, geometry: Bounds): Arrangement {
|
||
result = Row.arrange(self, mount, geometry)
|
||
result.children.at(0).y = 7
|
||
return result
|
||
}
|
||
}
|
||
class Small: Column {
|
||
function measure(self: Small, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(10, 30) }
|
||
}
|
||
first = RectBlock(20, 20)
|
||
second = RectBlock(40, 20)
|
||
placed = Placed(0, 0, first)
|
||
placed.crossAlign = "end"
|
||
small = Small(0, 0, second)
|
||
small.crossAlign = "center"
|
||
page = Page()
|
||
page.render(Column(0, 0, placed, small))
|
||
"#, "app.main", 295, 1).unwrap();
|
||
// 通过正常加载收敛并验证文档,以下检查读取最终 Mount 坐标而非临时 Arrangement。
|
||
initial.validate().unwrap();
|
||
// 即使父 crossAlign=end,源 arrange 已给定的 y=7 也不能被后续原生对齐覆盖。
|
||
assert_eq!(
|
||
session
|
||
.execute("first.mounts().at(0).bounds().y", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"7"
|
||
);
|
||
// 夹具子项自然宽为 40、父测量宽为 10;这里断言最终 x=0,没有再断言实际分配宽度或溢出像素。
|
||
assert_eq!(
|
||
session
|
||
.execute("second.mounts().at(0).bounds().x", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 带不对称 margin 的共享子项分别参与有限 flex Row 和自然尺寸 Row。
|
||
/// 检查定位、交叉轴居中、剩余宽度与容器固有尺寸;增加右 margin 同时挤窄 flex 兄弟并扩大自然父宽度。
|
||
fn margins_participate_in_intrinsic_size_alignment_and_flex_allocation() {
|
||
// 同一 first 带左右 3/7、上下 5/15 的 margin,分别参与有限分配与自然尺寸两种父布局。
|
||
let (mut session, initial) = ReplSession::load(r#"
|
||
class Wide: Row {
|
||
function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) }
|
||
}
|
||
first = RectBlock(20, 20)
|
||
first.marginLeft = 3
|
||
first.marginRight = 7
|
||
first.marginTop = 5
|
||
first.marginBottom = 15
|
||
second = RectBlock(20, 20)
|
||
second.flex = 1
|
||
row = Wide(10, 10, first, second)
|
||
row.crossAlign = "center"
|
||
intrinsic = Row(10, 10, first, RectBlock(30, 30))
|
||
page = Page()
|
||
page.render(Column(0, 0, row, intrinsic))
|
||
"#, "app.main", 296, 1).unwrap();
|
||
// 先验证文档,随后比较位置、兄弟分配和自然父尺寸,覆盖 margin 进入盒模型的各处。
|
||
initial.validate().unwrap();
|
||
// 有限 Row 的首项 x=10+3,后项 x=10+3+20+7+10;剩余宽 140 给 flex 项。
|
||
// 自然 Row 宽为 20 padding+30 首项外宽+10 gap+30 后项,高为 20 padding+40 最大外高。
|
||
for (expression, expected) in [
|
||
("first.mounts().at(0).bounds().x", "13"),
|
||
("first.mounts().at(0).bounds().y", "35"),
|
||
("second.mounts().at(0).bounds().x", "50"),
|
||
("second.mounts().at(0).bounds().width", "140"),
|
||
("intrinsic.mounts().at(0).bounds().width", "90"),
|
||
("intrinsic.mounts().at(0).bounds().height", "60"),
|
||
] {
|
||
// 逐表达式核对真实几何,错误信息保留表达式以区分定位与尺寸求解问题。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
expected,
|
||
"{expression}"
|
||
);
|
||
}
|
||
// 将共享 first 的右 margin 增加 10,两个出现位置都需刷新,但有限父和自然父的变化方向不同。
|
||
session
|
||
.execute("first.marginRight = 17", "app.main")
|
||
.unwrap();
|
||
// 有限 Row 总宽不变,flex 兄弟让出 10;自然 Row 则增加 10,证明 margin 不是只影响绘制偏移。
|
||
assert_eq!(
|
||
session
|
||
.execute("second.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"130"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("intrinsic.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"100"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 在固定尺寸 Overlay 内嵌带边框/不对称 padding 的 Column,子项还有 margin。
|
||
/// 比较三层绝对坐标与边框绘制命令,再动态改变 min/max,验证对齐与内容偏移使用同一盒模型。
|
||
fn nested_overlay_uses_border_boxes_and_asymmetric_content_insets() {
|
||
// 固定 Overlay 中放置固定 Column,Column 有非对称 padding/border,子项另有 margin,构造三层偏移。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r##"
|
||
child = RectBlock(20, 10)
|
||
child.marginLeft = 5
|
||
child.marginTop = 4
|
||
column = Column(0, 0, child)
|
||
column.boxWidth = 200
|
||
column.boxHeight = 120
|
||
column.paddingLeft = 11
|
||
column.paddingTop = 7
|
||
column.borderLeft = 3
|
||
column.borderTop = 2
|
||
column.borderColor = "#FF0000"
|
||
overlay = Overlay(10, 0, column)
|
||
overlay.boxWidth = 300
|
||
overlay.boxHeight = 200
|
||
overlay.mainAlign = "center"
|
||
overlay.crossAlign = "end"
|
||
page = Page()
|
||
page.render(overlay)
|
||
"##,
|
||
"app.main",
|
||
297,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 完成布局后逐层查几何,让容器对齐、内容内缩和子项 margin 使用同一坐标基准。
|
||
initial.validate().unwrap();
|
||
// Column 的外框在 (50,70),子项 x=50+3+11+5=69、y=70+2+7+4=83。
|
||
for (expression, expected) in [
|
||
("overlay.mounts().at(0).bounds().width", "300"),
|
||
("column.mounts().at(0).bounds().x", "50"),
|
||
("column.mounts().at(0).bounds().y", "70"),
|
||
("column.mounts().at(0).bounds().width", "200"),
|
||
("child.mounts().at(0).bounds().x", "69"),
|
||
("child.mounts().at(0).bounds().y", "83"),
|
||
] {
|
||
// 核对边框盒尺寸和绝对坐标,避免只看像素颜色而遗漏布局数值偏差。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
expected,
|
||
"{expression}"
|
||
);
|
||
}
|
||
// 红色左边框必须在 Column 外框左缘生成宽 3、高 120 的命令,不能画到内容起点。
|
||
assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { x, y, width, height, color, .. } if *x == 50.0 && *y == 70.0 && *width == 3.0 && *height == 120.0 && color == "#FF0000")));
|
||
// 同一 Column 的显式尺寸还要受 maxWidth/minHeight 夹取,字段更新后应变成 160×140。
|
||
session
|
||
.execute("column.maxWidth = 160\ncolumn.minHeight = 140", "app.main")
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("column.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"160"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("column.mounts().at(0).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"140"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 文字 boxWidth=fill 在有限父宽中获得整个边框盒,真正换行宽度再扣左右 padding。
|
||
/// 另构造无界 Column 主轴上的 fill 高度,应明确拒绝无法定义的有限分配。
|
||
fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() {
|
||
// 有限宽 160 的 Column 为 fill 文字分配边框盒,文字自身 padding 再减少实际排字宽度。
|
||
let (mut session, initial) = ReplSession::load(r#"
|
||
text = TextBlock("Text wrapping follows the finite border box rather than the original unbounded column width.")
|
||
text.boxWidth = "fill"
|
||
text.paddingLeft = 8
|
||
text.paddingRight = 12
|
||
column = Column(0, 0, text)
|
||
column.boxWidth = 160
|
||
page = Page()
|
||
page.render(column)
|
||
"#, "app.main", 298, 1).unwrap();
|
||
// 确认文档有效,并通过 Mount 查询核对 fill 后的外框宽度恰为 160。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("text.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"160"
|
||
);
|
||
// 排字起点右移 8,wrap 宽为 160−8−12=140;不能把外框宽直接当作文字可用宽。
|
||
assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::Text { x, wrap_bounds: Some([width, _]), .. } if *x == 8.0 && *width == 140.0)));
|
||
// 另一程序让自然高度 Column 中的子项 boxHeight=fill;主轴无有限分配,无法定义填满目标。
|
||
let error = ReplSession::load(
|
||
r#"
|
||
rect = RectBlock(10, 10)
|
||
rect.boxHeight = "fill"
|
||
page = Page()
|
||
page.render(Column(0, 0, rect))
|
||
"#,
|
||
"app.main",
|
||
299,
|
||
1,
|
||
)
|
||
.err()
|
||
.expect("unbounded fill must fail");
|
||
// 错误应明确说明 fill 缺少有限父分配,不能以任意其他加载失败替代该检查。
|
||
assert!(
|
||
format!("{error:#}").contains("fill requires a finite parent allocation"),
|
||
"{error:#}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 让子按钮超出带左边框且 overflow=clip 的固定 Row。
|
||
/// 检查子 Mount 的位置、clip 和整帧 hit_test 拒绝;此用例不调用软件光栅化或派发源回调。
|
||
fn clipping_a_box_limits_descendant_paint_and_hit_geometry() {
|
||
// 固定 Row 宽 60 且左边框宽 2,前项先占宽 100,后按钮保留位于父内容区之外的原几何。
|
||
let (_, initial) = ReplSession::load(r#"
|
||
class Overflow: RectBlock {
|
||
function measure(self: Overflow, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(100, 20) }
|
||
}
|
||
child = Button("outside")
|
||
child.boxWidth = 100
|
||
row = Row(0, 0, Overflow(100, 20), child)
|
||
row.boxWidth = 60
|
||
row.boxHeight = 40
|
||
row.borderLeft = 2
|
||
row.overflow = "clip"
|
||
page = Page()
|
||
page.render(row)
|
||
"#, "app.main", 300, 1).unwrap();
|
||
// 确认发布结构合法,随后从完整帧定位唯一可交互按钮。
|
||
initial.validate().unwrap();
|
||
// 按 interactive 找后按钮;裁剪不能通过删除 Mount 或篡改其原位置来实现。
|
||
let child = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.interactive)
|
||
.unwrap();
|
||
// 有效裁剪只覆盖扣去边框的父内容区 [2,0,58,40],子项 x 仍应在 100 之外。
|
||
assert_eq!(child.clip, Some([2.0, 0.0, 58.0, 40.0]));
|
||
assert!(child.x >= 100.0);
|
||
// 选择子矩形内部点调用整帧命中测试,仍必须因父裁剪而找不到目标。
|
||
assert!(
|
||
initial
|
||
.frame
|
||
.hit_test(crate::geometry::Point::new(child.x + 1.0, child.y + 1.0))
|
||
.is_none()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 两个 fill 子项分享扣去 margin/gap/固定项后的主轴空间,其中一项触及 maxWidth 后重新分配。
|
||
/// 交叉轴 stretch 仍受 maxHeight 和 alignSelf 限制;增大父宽后首项吸收新增空间。
|
||
fn main_axis_fill_shares_remaining_space_and_stretch_respects_box_limits() {
|
||
// 父宽 240 扣 padding 20、gap 10、首项 margin 5 和固定项 40,留给两项 fill 的总宽为 165。
|
||
// 第二项 maxWidth=50 后首项吸收剩余 115;交叉轴 stretch 另受 maxHeight/alignSelf 限制。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r#"
|
||
first = RectBlock(10, 10)
|
||
first.boxWidth = "fill"
|
||
first.marginLeft = 5
|
||
first.maxHeight = 30
|
||
second = RectBlock(10, 10)
|
||
second.boxWidth = "fill"
|
||
second.maxWidth = 50
|
||
fixed = RectBlock(40, 20)
|
||
fixed.alignSelf = "center"
|
||
row = Row(10, 5, first, second, fixed)
|
||
row.boxWidth = 240
|
||
row.boxHeight = 80
|
||
row.crossAlign = "stretch"
|
||
page = Page()
|
||
page.render(row)
|
||
"#,
|
||
"app.main",
|
||
301,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先验证整体文档,再独立检查主轴分配、交叉轴限制和固定项居中位置。
|
||
initial.validate().unwrap();
|
||
// 首项高度被 maxHeight 夹为 30,第二项填满内容高 60;固定项显式 center 得到 y=30。
|
||
for (expression, expected) in [
|
||
("first.mounts().at(0).bounds().width", "115"),
|
||
("second.mounts().at(0).bounds().width", "50"),
|
||
("first.mounts().at(0).bounds().height", "30"),
|
||
("second.mounts().at(0).bounds().height", "60"),
|
||
("fixed.mounts().at(0).bounds().y", "30"),
|
||
] {
|
||
// 从各自 Mount 读取最终分配,不能把业务自然尺寸当作 fill/stretch 的结果。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
expected,
|
||
"{expression}"
|
||
);
|
||
}
|
||
// 父宽增加 40,第二项仍卡在最大宽 50,新增空间应全部给首项,得到 155。
|
||
session.execute("row.boxWidth = 280", "app.main").unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("first.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"155"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 通过真实 VM 加载盒布局示例并验证完整文档。
|
||
/// 共享文字有两个 Mount,父盒宽度不同产生不同分配,防止示例只在说明文档中成立。
|
||
fn box_layout_example_runs_through_the_real_source_pipeline() {
|
||
// 直接加载仓库中的盒布局示例,以真实 VM、桥接布局和文档发布检验示例可执行性。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/box-layout.endor"),
|
||
"app.main",
|
||
302,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 共享文字必须有两个 Mount,才能比较不同父盒约束下的独立分配。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
// 取得第一个出现的最终宽度并转为数值,避免字符串字典序比较。
|
||
let left = session
|
||
.execute("shared.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse::<f32>()
|
||
.unwrap();
|
||
// 第二个出现单独读宽,较窄父盒应得到更小分配,不能共享第一次测量结果。
|
||
let right = session
|
||
.execute("shared.mounts().at(1).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse::<f32>()
|
||
.unwrap();
|
||
assert!(left > right);
|
||
}
|
||
|
||
#[test]
|
||
/// 固定 Overlay 按内容高度拉伸子项,再受 margin/maxHeight 限制得到 50。
|
||
/// 改为显式 boxHeight 后使用 15,证明显式尺寸优先于 stretch;本例未独立比较兄弟项前后位置。
|
||
fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() {
|
||
// Overlay 高 80 扣上下 padding 10 和 child 顶 margin 3,stretch 原可分配 67,再受 maxHeight=50 限制。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r#"
|
||
child = RectBlock(20, 10)
|
||
child.marginTop = 3
|
||
child.maxHeight = 50
|
||
overlay = Overlay(5, 0, child, RectBlock(10, 10))
|
||
overlay.boxWidth = 100
|
||
overlay.boxHeight = 80
|
||
overlay.crossAlign = "stretch"
|
||
page = Page()
|
||
page.render(overlay)
|
||
"#,
|
||
"app.main",
|
||
303,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 文档有效后核对 child 高 50 且 y=5+3=8,验证 margin 在拉伸与定位中一致使用。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("child.mounts().at(0).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"50"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("child.mounts().at(0).bounds().y", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"8"
|
||
);
|
||
// 改为显式高度 15 后应优先采用该值,父 stretch 不得再次覆盖它。
|
||
session.execute("child.boxHeight = 15", "app.main").unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("child.mounts().at(0).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"15"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 两个有限视口共享同一 Scene2D,默认相机独立,出现位置各有变换和裁剪。
|
||
/// 点击先经变换命中;只改首个相机不测量业务对象,显式共享 camera 后两边才同步缩放。
|
||
fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() {
|
||
// 两个有限 CanvasViewport 共享同一 Scene2D/button,初始只修改 first 的默认相机。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r#"
|
||
hits = ui.state(0)
|
||
button = Button("shared")
|
||
button.onClick(hits.increment)
|
||
button.x = 40
|
||
button.y = 20
|
||
scene = Scene2D(button)
|
||
first = CanvasViewport(200, 100, scene)
|
||
second = CanvasViewport(200, 100, scene)
|
||
first.camera.x = 20
|
||
first.camera.zoom = 2
|
||
page = Page()
|
||
page.render(Row(0, 20, first, second))
|
||
"#,
|
||
"app.main",
|
||
304,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 共享业务按钮应拥有两个 Mount;相机和裁剪属于各出现上下文,而非按钮自身。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("button.mounts().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
// 按文档顺序收集两处交互 Mount,分别核对缩放变换与左右面板裁剪。
|
||
let buttons: Vec<_> = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.filter(|mount| mount.interactive)
|
||
.collect();
|
||
// 首面板 zoom=2,第二面板默认相机无额外变换;两者 clip 分别位于 x=0 和 x=220。
|
||
assert_eq!(buttons.len(), 2);
|
||
assert_eq!(buttons[0].view.unwrap().zoom, 2.0);
|
||
assert!(buttons[1].view.is_none());
|
||
assert_eq!(buttons[0].clip, Some([0.0, 0.0, 200.0, 100.0]));
|
||
assert_eq!(buttons[1].clip, Some([220.0, 0.0, 200.0, 100.0]));
|
||
// 保存首个出现身份,后续改相机应保留该 ID。
|
||
let first_id = buttons[0].id;
|
||
// 选取经首相机投影后位于按钮内的屏幕坐标,命中必须先还原场景位置。
|
||
let point = [45.0, 45.0];
|
||
// 先核对整帧命中选择,再沿原生派发路径发送同一坐标,确保几何与事件路由一致。
|
||
assert_eq!(
|
||
initial
|
||
.frame
|
||
.hit_test(crate::geometry::Point::new(point[0], point[1]))
|
||
.unwrap()
|
||
.id,
|
||
first_id
|
||
);
|
||
// 有效文档版本下点击首个出现,只有共享业务计数递增一次。
|
||
session
|
||
.dispatch(&crate::events::Input {
|
||
window: crate::window::MAIN_WINDOW,
|
||
session: initial.frame.session,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: first_id,
|
||
kind: "click".into(),
|
||
point: Some(point),
|
||
press_point: Some(point),
|
||
delta: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap();
|
||
assert_eq!(
|
||
session.execute("hits.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
// 只修改 first.camera 平移/缩放,第二面板不能被共享场景对象带着改变。
|
||
let changed = session
|
||
.execute("first.camera.x = -40\nfirst.camera.zoom = 1", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 重新发布后的交互 Mount 仍按各面板独立投影,几何结构应有效。
|
||
changed.validate().unwrap();
|
||
// 从新文档重新取出现位置,不能借用旧帧中的 view 判断更新是否生效。
|
||
let buttons: Vec<_> = changed
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.filter(|mount| mount.interactive)
|
||
.collect();
|
||
// 首个 Mount 身份保持、平移变为 40;第二个仍无额外变换,业务测量数为零。
|
||
assert_eq!(buttons[0].id, first_id);
|
||
assert_eq!(buttons[0].view.unwrap().x, 40.0);
|
||
assert!(buttons[1].view.is_none());
|
||
assert_eq!(source_measure_stat(&mut session, "measured"), 0);
|
||
// 显式把 second.camera 指向 first.camera 后再缩放,此时两个面板才应共享相机状态。
|
||
let shared = session
|
||
.execute(
|
||
"second.camera = first.camera\nfirst.camera.zoom = 2",
|
||
"app.main",
|
||
)
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 验证新帧并检查所有交互出现都采用 zoom=2,区分显式共享和初始默认独立。
|
||
// all 只校验枚举到的项目;这一分支没有再次断言交互出现数,数量基线由前面的初始帧检查建立。
|
||
shared.validate().unwrap();
|
||
assert!(
|
||
shared
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.filter(|mount| mount.interactive)
|
||
.all(|mount| mount.view.is_some_and(|view| view.zoom == 2.0))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 嵌套二维相机的缩放相乘、平移复合,检查最终变换和相交裁剪。
|
||
/// 手势只驱动内层相机,外层不动;最后软件绘制采样确认固定 HUD 与变换后内容均位于预期像素。
|
||
fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() {
|
||
// 内相机缩放 0.5、外相机缩放 2,嵌套面板位置参与复合变换;HUD 是外部 Overlay 的独立兄弟。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r##"
|
||
button = Button("nested")
|
||
button.boxWidth = 20
|
||
button.boxHeight = 20
|
||
button.x = 10
|
||
button.y = 10
|
||
inner = CanvasViewport(80, 60, Scene2D(button))
|
||
inner.camera.zoom = 0.5
|
||
inner.x = 20
|
||
inner.y = 20
|
||
outer = CanvasViewport(200, 200, Scene2D(inner))
|
||
outer.camera.zoom = 2
|
||
hud = RectBlock(10, 10, 0, "#0000FF")
|
||
page = Page()
|
||
page.render(Overlay(0, 0, outer, hud))
|
||
"##,
|
||
"app.main",
|
||
305,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先确认发布结构,再定位嵌套按钮,检查复合投影及可见区域。
|
||
initial.validate().unwrap();
|
||
// 唯一可交互节点来自最内 Scene2D,携带两层相机叠加后的有效变换。
|
||
let button = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.interactive)
|
||
.unwrap();
|
||
// 最终 zoom=1,Transform 按 point×zoom+(x,y) 投影,平移为 (20,20);clip 在视图坐标中比较。
|
||
assert_eq!(
|
||
button.view.unwrap(),
|
||
crate::viewport::Transform {
|
||
zoom: 1.0,
|
||
x: 20.0,
|
||
y: 20.0
|
||
}
|
||
);
|
||
assert_eq!(button.clip, Some([40.0, 40.0, 160.0, 120.0]));
|
||
// 屏幕点 (55,55) 同时用于交互命中和选取最内层可控相机。
|
||
let point = crate::geometry::Point::new(55.0, 55.0);
|
||
assert_eq!(initial.frame.hit_test(point).unwrap().id, button.id);
|
||
// camera_at 应优先命中内层视口,平移手势不能误控制外层相机。
|
||
let inner_id = initial.frame.camera_at(point).unwrap().id;
|
||
// 构造针对内层视口的 viewport-pan,delta 通过宿主相机手势入口消费。
|
||
let input = crate::events::Input {
|
||
window: 1,
|
||
session: initial.frame.session,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: inner_id,
|
||
kind: "viewport-pan".into(),
|
||
delta: Some([10.0, 0.0]),
|
||
point: None,
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
};
|
||
// 派发后内相机 x 应变为 10,外相机 x 仍为零,验证手势目标选择。
|
||
session.dispatch(&input).unwrap();
|
||
assert_eq!(
|
||
session.execute("inner.camera.x", "app.main").unwrap().value,
|
||
"10"
|
||
);
|
||
assert_eq!(
|
||
session.execute("outer.camera.x", "app.main").unwrap().value,
|
||
"0"
|
||
);
|
||
// 显式使 HUD paint 失效以取得最新完整文档,再检验真实像素而非仅投影字段。
|
||
let document = session
|
||
.execute("hud.invalidate(\"paint\")", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 分配 200×200 软件输出缓冲,1x DPI 下采样点与逻辑坐标一致。
|
||
let mut pixels = vec![0; 200 * 200];
|
||
// 执行真实光栅化以核对叠加层与变换后内容,补足仅检查投影字段无法覆盖的绘制路径。
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&document.display_list(1.0).unwrap(),
|
||
&mut pixels,
|
||
200,
|
||
200,
|
||
)
|
||
.unwrap();
|
||
// HUD 采样点必须为蓝色;另一点只要求与远处背景不同,没有断言具体按钮颜色或整图一致性。
|
||
assert_eq!(pixels[5 * 200 + 5], 0x0000FF);
|
||
assert_ne!(pixels[55 * 200 + 45], pixels[190 * 200 + 190]);
|
||
}
|
||
|
||
#[test]
|
||
/// 在负世界 x 处放红矩形,经相机平移和二倍缩放映射到有限视口。
|
||
/// 实际像素需显示固定蓝色覆盖层、可见红块和视口外背景,检验命令投影与裁剪最终进入光栅化。
|
||
fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() {
|
||
// 红矩形位于负世界 x,经相机平移与二倍缩放进入 50×50 视口;蓝色 HUD 不随相机变化。
|
||
let (_, initial) = ReplSession::load(
|
||
r##"
|
||
rect = RectBlock(20, 20, 0, "#FF0000")
|
||
rect.x = -10
|
||
rect.y = 10
|
||
view = CanvasViewport(50, 50, Scene2D(rect))
|
||
view.camera.x = -10
|
||
view.camera.zoom = 2
|
||
page = Page()
|
||
page.render(Overlay(0, 0, view, RectBlock(10, 10, 0, "#0000FF")))
|
||
"##,
|
||
"app.main",
|
||
306,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 文档通过结构验证后再做像素测试,区分投影数据有效与实际绘制正确。
|
||
initial.validate().unwrap();
|
||
// 输出 80×80,大于有限视口,留出明确的视口外背景采样点。
|
||
let mut pixels = vec![0; 80 * 80];
|
||
// 用 1x 显示列表运行软件绘制,包含世界投影、面板裁剪和 Overlay 绘制顺序。
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&initial.display_list(1.0).unwrap(),
|
||
&mut pixels,
|
||
80,
|
||
80,
|
||
)
|
||
.unwrap();
|
||
// 依次核对固定蓝层、可见红块和两个背景对照点;背景按输出右下角取样,不绑定固定底色。
|
||
// 这是指定像素的光栅回归,不覆盖全部边缘、所有 DPI 或完整图像差分。
|
||
assert_eq!(pixels[5 * 80 + 5], 0x0000FF);
|
||
assert_eq!(pixels[25 * 80 + 5], 0xFF0000);
|
||
assert_eq!(pixels[55 * 80 + 5], pixels[79 * 80 + 79]);
|
||
assert_eq!(pixels[25 * 80 + 45], pixels[79 * 80 + 79]);
|
||
}
|
||
|
||
#[test]
|
||
/// 加载场景视口示例并确认共享按钮出现两次。
|
||
/// 再实际构造 ScrollView,检查它保持 ScrollBlock 名义类型兼容,而非只在文本说明中存在的别名。
|
||
fn scene_viewport_example_and_scroll_view_alias_run_as_real_source() {
|
||
// 直接执行场景视口示例,确认共享 increment 按钮在两个视口中各有一个出现。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/scene-viewports.endor"),
|
||
"app.main",
|
||
307,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先检查示例文档和出现数量,再单独验证 ScrollView 的实际构造与名义类型兼容。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("increment.mounts().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
// 源码构造 ScrollView 实例并保存在全局,不只是检查名字可查询或文档宣称存在别名。
|
||
session
|
||
.execute(
|
||
"testScroll = ScrollView(100, 60, Row(0, 0, TextBlock(\"scroll\")))",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 新实例满足 ScrollBlock 名义类型;本例只检查类型关系,没有挂载 testScroll 或实际调用其滚动方法。
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.isInstance(testScroll, ScrollBlock)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 场景内容在负坐标,按当前 Mount 的有限尺寸 fitView。
|
||
/// 比较相机 zoom/pan 与矩形投影后的 48px 留白,验证适配的是场景边界而非业务对象原始正坐标假设。
|
||
fn fit_view_uses_finite_scene_bounds_and_this_mounts_size() {
|
||
// 场景矩形跨负坐标,视口固定为 296×196,测试 fitView 不假设世界内容从原点向正方向展开。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r#"
|
||
rect = RectBlock(100, 50)
|
||
rect.x = -100
|
||
rect.y = -25
|
||
view = CanvasViewport(296, 196, Scene2D(rect))
|
||
page = Page()
|
||
page.render(view)
|
||
"#,
|
||
"app.main",
|
||
308,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 基线文档有效后,针对视口的具体 Mount 调用 fitView,使用该出现位置的有限尺寸。
|
||
initial.validate().unwrap();
|
||
// fitView 更新相机并发布新文档,后续投影必须从新帧取值。
|
||
let document = session
|
||
.execute("view.mounts().at(0).fitView()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 验证适配后的完整文档,再比较相机缩放和平移结果。
|
||
document.validate().unwrap();
|
||
// 扣两边各 48 的留白后,宽约束允许 zoom=2;x/y 还需补偿负世界边界以居中放置内容。
|
||
for (expression, expected) in [
|
||
("view.camera.zoom", "2"),
|
||
("view.camera.x", "-124"),
|
||
("view.camera.y", "-49"),
|
||
] {
|
||
// 直接读取源相机状态,排除只在绘制命令上临时移动而未更新业务相机的实现。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
expected
|
||
);
|
||
}
|
||
// 从适配后的帧找场景矩形,其逻辑几何保持 100×50,仅视图变换改变。
|
||
let rect = document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.width == 100.0 && mount.height == 50.0)
|
||
.unwrap();
|
||
// 用出现位置附带的 view 投影矩形左上角,应落在 (48,48),与相机字段断言互相核对。
|
||
let screen = rect
|
||
.view
|
||
.unwrap()
|
||
.point(crate::geometry::Point::new(rect.x, rect.y));
|
||
assert_eq!(screen, crate::geometry::Point::new(48.0, 48.0));
|
||
}
|
||
|
||
#[test]
|
||
/// 同一场景用透视和正交相机各显示一次,前景蓝盒应遮住后景红盒。
|
||
/// 比较拾取距离、交点和实际中心像素;移动前景并只修改透视相机后,两视图露出后景且相机 z 保持独立。
|
||
fn real_scene3d_renders_depth_with_independent_perspective_and_orthographic_cameras() {
|
||
// 透视与正交视口共享前蓝后红两个盒子,ambient=1 便于用准确材质色比较遮挡像素。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r##"
|
||
back = Box3D(2, 2, 2, "#FF0000")
|
||
front = Box3D(1, 1, 1, "#0000FF")
|
||
front.z = 1.5
|
||
scene = Scene3D(front, back)
|
||
scene.ambient = 1
|
||
perspective = Viewport3D(120, 100, scene)
|
||
orthographic = Viewport3D(120, 100, scene)
|
||
orthographic.camera = OrthographicCamera()
|
||
orthographic.camera.height = 4
|
||
page = Page()
|
||
page.render(Row(0, 10, perspective, orthographic))
|
||
"##,
|
||
"app.main",
|
||
309,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 确认首帧合法,再从文档命令中取两套实际三维视图,不重新构造近似渲染数据。
|
||
initial.validate().unwrap();
|
||
// 只保留 Scene3D 命令里的 view,按左右视口顺序用于相机和射线断言。
|
||
let commands: Vec<_> = initial
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.filter_map(|command| {
|
||
// 三维命令携带渲染/拾取使用的同一场景快照,其他 UI 命令不参与本组比较。
|
||
if let RenderCommand::Scene3D { view, .. } = command {
|
||
Some(view)
|
||
} else {
|
||
// 跳过容器等非三维命令,避免把显示列表全部命令数误当作视口数量。
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
assert_eq!(commands.len(), 2);
|
||
// 透视视口中心射线应先击中前盒 z=2 的正面,距离相机 3,不能依场景子项次序选择后盒。
|
||
let hit = commands[0].pick(0.5, 0.5, 1.2).unwrap();
|
||
assert!((hit.distance - 3.0).abs() < 1e-5);
|
||
assert_eq!(hit.point, crate::scene3d::math::Vec3::new(0., 0., 2.));
|
||
// 第二个视图必须保留显式正交投影 height=4,不能因共享 scene 误用透视相机。
|
||
assert!(matches!(
|
||
commands[1].camera.projection,
|
||
crate::scene3d::math::Projection::Orthographic { height: 4.0 }
|
||
));
|
||
// 输出 250×100,覆盖两个宽 120、间距 10 的视口,左右中心点分别位于 x=60/190。
|
||
let mut pixels = vec![0; 250 * 100];
|
||
// 首帧实际光栅化验证深度遮挡,两视口中心均应显示前盒蓝色。
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&initial.display_list(1.).unwrap(),
|
||
&mut pixels,
|
||
250,
|
||
100,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(pixels[50 * 250 + 60], 0x0000FF);
|
||
assert_eq!(pixels[50 * 250 + 190], 0x0000FF);
|
||
// 把前盒移出中心,同时只把透视相机 z 改为 8;正交相机应保持初始 z=5。
|
||
let updated = session
|
||
.execute("front.x=10\nperspective.camera.z=8", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 新文档必须有效,再检查命令快照里的两台相机位置。
|
||
updated.validate().unwrap();
|
||
// 从更新后的 Scene3D 命令收集 z,验证共享的是场景而非两个默认相机对象。
|
||
let cameras: Vec<_> = updated
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.filter_map(|command| {
|
||
// 仅三维视图提供 camera.position,仍按发布顺序比较左右相机。
|
||
if let RenderCommand::Scene3D { view, .. } = command {
|
||
Some(view.camera.position.z)
|
||
} else {
|
||
// 非场景命令不提供相机数据,不参与预期的 [8,5] 列表。
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
assert_eq!(cameras, vec![8., 5.]);
|
||
// 复用像素缓冲绘制新帧;两个中心都应露出后盒红色,不能保留上一帧深度或颜色。
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&updated.display_list(1.).unwrap(),
|
||
&mut pixels,
|
||
250,
|
||
100,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(pixels[50 * 250 + 60], 0xFF0000);
|
||
assert_eq!(pixels[50 * 250 + 190], 0xFF0000);
|
||
}
|
||
|
||
#[test]
|
||
/// 先通过 Mount.pick3D 查询中心交点与真实 mesh 材质,再从原生输入派发同位置 click。
|
||
/// 回调写 event.hit.mesh.color 后业务对象及最终像素都变绿,证明拾取结果保留正确托管业务身份。
|
||
fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() {
|
||
// 源 Actions 回调通过 event.hit.mesh 改色,验证拾取结果能映射回原托管业务 Mesh。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r##"
|
||
class Actions {
|
||
function select(self: Actions, event: Event): Unit { event.hit.mesh.color = "#00FF00" }
|
||
}
|
||
mesh = Box3D(2,2,2,"#FF0000")
|
||
scene = Scene3D(mesh)
|
||
scene.ambient = 1
|
||
view = Viewport3D(100,100,scene)
|
||
actions = Actions()
|
||
view.onClick(actions.select)
|
||
page = Page()
|
||
page.render(view)
|
||
"##,
|
||
"app.main",
|
||
310,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先在源 API 查询中心拾取:交点 z=1 且材质为红,建立原生输入前的拾取基线。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("view.mounts().at(0).pick3D(50,50).point.z", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"1"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("view.mounts().at(0).pick3D(50,50).mesh.color", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"\"#FF0000\""
|
||
);
|
||
// 显式请求重绘取得最新帧,确保随后原生事件携带当前布局版本。
|
||
let frame = session
|
||
.execute("view.invalidate(\"paint\")", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap()
|
||
.frame;
|
||
// 以 scene3d_viewport 标记选实际三维视口 Mount,而非场景内部网格或普通按钮。
|
||
let mount = frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.scene3d_viewport)
|
||
.unwrap();
|
||
// 原生 click 的按下/释放均在视口中心;宿主应附带对应 Hit3D 交给源回调。
|
||
let updated = session
|
||
.dispatch(&crate::events::Input {
|
||
window: 1,
|
||
session: frame.session,
|
||
layout_version: frame.layout_version,
|
||
mount: mount.id,
|
||
kind: "click".into(),
|
||
point: Some([50., 50.]),
|
||
press_point: Some([50., 50.]),
|
||
delta: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 从原全局 mesh 读到绿色,证明 event.hit.mesh 没有指向脱离业务对象的临时副本。
|
||
assert_eq!(
|
||
session.execute("mesh.color", "app.main").unwrap().value,
|
||
"\"#00FF00\""
|
||
);
|
||
// 再准备 100×100 软件像素缓冲,检验业务材质改动确实进入绘制依赖与最终渲染。
|
||
let mut pixels = vec![0; 100 * 100];
|
||
// 绘制派发返回的新文档,中心像素必须为绿色,与源字段查询结果一致。
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&updated.display_list(1.).unwrap(),
|
||
&mut pixels,
|
||
100,
|
||
100,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(pixels[50 * 100 + 50], 0x00FF00);
|
||
}
|
||
|
||
#[test]
|
||
/// 运行内置三维示例及其旋转动作,再替换为源构造的自定义三角网格。
|
||
/// 另检查 Group3D 自循环和越界三角索引的明确错误,非法场景不能进入发布。
|
||
fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() {
|
||
// 执行仓库的三维示例,用源 Actions.rotate 验证实际交互业务可更新场景。
|
||
let (mut session, document) = ReplSession::load(
|
||
include_str!("../../../examples/scene3d.endor"),
|
||
"app.main",
|
||
311,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 首帧结构必须有效,动作执行后 cube.rotationY 应更新为 40。
|
||
document.validate().unwrap();
|
||
session.execute("actions.rotate()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session.execute("cube.rotationY", "app.main").unwrap().value,
|
||
"40"
|
||
);
|
||
// 在原会话替换为三个顶点、一个三角面的自定义 Geometry3D,要求产生有效新文档。
|
||
// 此处重设 scene.children 属性,不是在原 ObjectList 上 append/remove,不能覆盖列表内容依赖的原地编辑路径。
|
||
session.execute(r##"
|
||
geometry=Geometry3D(ObjectList(Vec3(-1,-1,0),Vec3(1,-1,0),Vec3(0,1,0)),ObjectList(Triangle3D(0,1,2)))
|
||
custom=Mesh3D(geometry,"#112233")
|
||
scene.children=ObjectList(custom)
|
||
"##,"app.main").unwrap().document.unwrap().validate().unwrap();
|
||
// 独立程序令 Group3D.children 包含自己,必须明确拒绝场景图循环而非无限遍历。
|
||
let error = ReplSession::load(
|
||
r##"
|
||
group=Group3D()
|
||
scene=Scene3D(group)
|
||
group.children=ObjectList(group)
|
||
page=Page()
|
||
page.render(Viewport3D(100,100,scene))
|
||
"##,
|
||
"app.main",
|
||
312,
|
||
1,
|
||
)
|
||
.err()
|
||
.unwrap();
|
||
// 检查完整错误链指向三维图循环,避免任意解析错误也通过。
|
||
assert!(
|
||
format!("{error:#}").contains("cyclic 3D scene graph"),
|
||
"{error:#}"
|
||
);
|
||
// 另一个程序仅给一个顶点却引用索引 0/1/2,应在 Geometry3D 缓冲验证时拒绝。
|
||
let error = ReplSession::load(
|
||
r##"
|
||
geometry=Geometry3D(ObjectList(Vec3(0,0,0)),ObjectList(Triangle3D(0,1,2)))
|
||
"##,
|
||
"app.main",
|
||
313,
|
||
1,
|
||
)
|
||
.err()
|
||
.unwrap();
|
||
// 区分非法几何缓冲与前一场景循环诊断,确保两条边界都有覆盖。
|
||
assert!(
|
||
format!("{error:#}").contains("invalid Geometry3D buffers"),
|
||
"{error:#}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 旋转 Panel3D 的 UI 以独立 100x100 分辨率布局,检查 toView/toLocal、射线命中和纹理像素。
|
||
/// 点击及源 paint 字段修改保持响应;加入前景实体后同一点只命中外层三维视口,UI 不可穿透遮挡。
|
||
fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() {
|
||
// 源 Tile.paint 读取 tint 并创建命令,UI 在旋转 Panel3D 的 100×100 纹理空间布局,再投影到 200×200 视口。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r##"
|
||
class Tile: Button {
|
||
tint: String
|
||
function paint(self: Tile, mount: Mount, geometry: Bounds): DisplayList {
|
||
fill=PaintCommand()
|
||
fill.kind="roundedRect"
|
||
fill.x=geometry.x
|
||
fill.y=geometry.y
|
||
fill.width=geometry.width
|
||
fill.height=geometry.height
|
||
fill.radius=0
|
||
fill.color=self.tint
|
||
return DisplayList(ObjectList(fill))
|
||
}
|
||
}
|
||
count=ui.state(0)
|
||
button=Tile("world UI","#FF0000")
|
||
button.onClick(count.increment)
|
||
panel=Panel3D(2,2,button)
|
||
panel.uiWidth=100
|
||
panel.uiHeight=100
|
||
panel.rotationY=25
|
||
scene=Scene3D(panel)
|
||
scene.ambient=1
|
||
view=Viewport3D(200,200,scene)
|
||
page=Page()
|
||
page.render(view)
|
||
"##,
|
||
"app.main",
|
||
314,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先验证发布文档,再定位携带 panel 投影上下文的可交互按钮,排除普通屏幕 UI。
|
||
initial.validate().unwrap();
|
||
// 取得面板内真实 Mount;其逻辑宽高应按 UI 分辨率,而不是面板世界尺寸 2×2。
|
||
let button = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.interactive && mount.panel.is_some())
|
||
.unwrap();
|
||
assert_eq!((button.width, button.height), (100., 100.));
|
||
// 将纹理中心 (50,50) 经旋转面板投影到屏幕,后面命中、输入和像素采样使用同一位置。
|
||
let point = button
|
||
.panel
|
||
.as_ref()
|
||
.unwrap()
|
||
.project_point(crate::geometry::Point::new(50., 50.))
|
||
.unwrap();
|
||
// 允许浮点误差,但投影中心必须落在屏幕 (100,100),整帧命中也必须选择该 UI Mount。
|
||
assert!((point.x - 100.).abs() < 0.001 && (point.y - 100.).abs() < 0.001);
|
||
assert_eq!(initial.frame.hit_test(point).unwrap().id, button.id);
|
||
assert_eq!(
|
||
session
|
||
.execute("button.mounts().at(0).toView(50,50).x", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"100"
|
||
);
|
||
// 此处 toView/toLocal 仅比较 x 分量,没有独立断言两个源 API 的 y;完整中心点另由上方原生投影检查。
|
||
// 从源 Mount.toLocal 反投影屏幕中心,数值解析后 x 应近似恢复纹理坐标 50。
|
||
let local = session
|
||
.execute("button.mounts().at(0).toLocal(100,100).x", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.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,
|
||
200,
|
||
200,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(pixels[100 * 200 + 100], 0xFF0000);
|
||
// 显式重绘取得最新布局版本;原按钮 Mount 身份保留,用该新版本构造随后点击。
|
||
let latest = session
|
||
.execute("button.invalidate(\"paint\")", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 原生输入携带投影后的屏幕坐标,宿主需通过面板射线映射到源按钮再调用回调。
|
||
let input = crate::events::Input {
|
||
window: 1,
|
||
session: latest.frame.session,
|
||
layout_version: latest.frame.layout_version,
|
||
mount: button.id,
|
||
kind: "click".into(),
|
||
point: Some([point.x, point.y]),
|
||
press_point: Some([point.x, point.y]),
|
||
delta: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
};
|
||
// 点击后共享 count 恰好递增一次,证明命中最终到达真实源回调。
|
||
session.dispatch(&input).unwrap();
|
||
assert_eq!(
|
||
session.execute("count.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
// 只改 paint 读取过的 tint;依赖系统应重新生成纹理并发布,不能要求重建面板。
|
||
let updated = session
|
||
.execute("button.tint=\"#00FF00\"", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 新文档有效后复用缓冲绘制,屏幕中心应从红变绿,验证源字段到三维纹理的更新链。
|
||
updated.validate().unwrap();
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&updated.display_list(1.).unwrap(),
|
||
&mut pixels,
|
||
200,
|
||
200,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(pixels[100 * 200 + 100], 0x00FF00);
|
||
// 在面板前方加入实体蓝盒,保持原 UI 仍挂载,单独检验深度遮挡对命中和像素的影响。
|
||
let blocked = session
|
||
.execute(
|
||
r##"
|
||
blocker=Box3D(1.2,1.2,1.2,"#0000FF")
|
||
blocker.z=1.5
|
||
scene.children=ObjectList(panel,blocker)
|
||
"##,
|
||
"app.main",
|
||
)
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 遮挡后的文档仍合法,从新帧重取 UI Mount,不使用旧的可见性快照。
|
||
blocked.validate().unwrap();
|
||
// 按钮虽仍存在且 interactive,但屏幕中心已被近处网格挡住。
|
||
let button = blocked
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.panel.is_some() && mount.interactive)
|
||
.unwrap();
|
||
// 面板 UI 的 contains 应因遮挡失败;整帧命中回落到外层三维视口。
|
||
// 此处只查询命中与随后像素,没有再派发被遮挡 click,也没有以 count 断言该次回调被拒绝。
|
||
assert!(!button.contains(point));
|
||
assert!(blocked.frame.hit_test(point).unwrap().scene3d_viewport);
|
||
// 最终像素也必须显示前景蓝盒,与命中采用同一场景深度关系。
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&blocked.display_list(1.).unwrap(),
|
||
&mut pixels,
|
||
200,
|
||
200,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(pixels[100 * 200 + 100], 0x0000FF);
|
||
}
|
||
|
||
#[test]
|
||
/// 把可交互的内层 Viewport3D 作为旋转世界面板的 UI 内容。
|
||
/// 中心点 camera_at/hit_test 必须找到内层视口,click 的 hit.mesh 更新内层立方体并通过两层表面呈现绿像素。
|
||
fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() {
|
||
// 外三维场景的旋转面板以另一 Viewport3D 为 UI 内容;内场景立方体颜色由源 click 的 hit.mesh 修改。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r##"
|
||
class Actions {
|
||
function select(self: Actions,event: Event): Unit { event.hit.mesh.color="#00FF00" }
|
||
}
|
||
cube=Box3D(2,2,2,"#0000FF")
|
||
inner_scene=Scene3D(cube)
|
||
inner_scene.ambient=1
|
||
inner=Viewport3D(100,100,inner_scene)
|
||
actions=Actions()
|
||
inner.onClick(actions.select)
|
||
panel=Panel3D(2,2,inner)
|
||
panel.uiWidth=100
|
||
panel.uiHeight=100
|
||
panel.rotationY=20
|
||
outer=Viewport3D(200,200,Scene3D(panel))
|
||
page=Page()
|
||
page.render(outer)
|
||
"##,
|
||
"app.main",
|
||
315,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先确认两层表面发布有效,再找同时具有 scene3d_viewport 与 panel 上下文的内层视口。
|
||
initial.validate().unwrap();
|
||
// 必须选内层视口,而非外层 200×200 视口,否则事件可能修改错误场景。
|
||
let inner = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.scene3d_viewport && mount.panel.is_some())
|
||
.unwrap();
|
||
// 两层中心投影到屏幕 (100,100);相机选择和普通命中均应优先进入内层视口。
|
||
let point = crate::geometry::Point::new(100., 100.);
|
||
assert_eq!(initial.frame.camera_at(point).unwrap().id, inner.id);
|
||
assert_eq!(initial.frame.hit_test(point).unwrap().id, inner.id);
|
||
// 先检查未点击时穿过面板纹理采样到内立方体蓝色,建立渲染基线。
|
||
let mut pixels = vec![0; 200 * 200];
|
||
// 对完整外帧光栅化,覆盖内视口渲染、面板纹理采样与外场景投影的组合。
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&initial.display_list(1.).unwrap(),
|
||
&mut pixels,
|
||
200,
|
||
200,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(pixels[100 * 200 + 100], 0x0000FF);
|
||
// 以外部屏幕坐标向内层 Mount 派发,宿主必须先反投影面板再执行内场景射线拾取。
|
||
let updated = session
|
||
.dispatch(&crate::events::Input {
|
||
window: 1,
|
||
session: initial.frame.session,
|
||
layout_version: initial.frame.layout_version,
|
||
mount: inner.id,
|
||
kind: "click".into(),
|
||
point: Some([100., 100.]),
|
||
press_point: Some([100., 100.]),
|
||
delta: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 回调改色后要求有效新文档,中心绿像素证明内 Mesh 更新经过两层表面正确呈现。
|
||
updated.validate().unwrap();
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&updated.display_list(1.).unwrap(),
|
||
&mut pixels,
|
||
200,
|
||
200,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(pixels[100 * 200 + 100], 0x00FF00);
|
||
}
|
||
|
||
#[test]
|
||
/// 先让 Panel.content 指回包含自身的视口,检查跨 UI/场景组合循环。
|
||
/// 再构造九层合法无环嵌套,检查超过八层表面预算的诊断;两种边界分别验证,避免把深度错误混同循环。
|
||
fn world_ui_cycles_and_surface_nesting_are_rejected_before_publication() {
|
||
// panel.content 指回包含 panel 的 view,形成跨 UI/三维场景的组合环,加载时必须拒绝。
|
||
let error = ReplSession::load(
|
||
r##"
|
||
panel=Panel3D(2,2,Button("cycle"))
|
||
view=Viewport3D(200,200,Scene3D(panel))
|
||
panel.content=view
|
||
page=Page()
|
||
page.render(view)
|
||
"##,
|
||
"app.main",
|
||
316,
|
||
1,
|
||
)
|
||
.err()
|
||
.unwrap();
|
||
// 检查完整诊断链明确报告 Block composition 循环,不能把任意布局错误当成循环保护。
|
||
assert!(
|
||
format!("{error:#}").contains("cyclic Block composition"),
|
||
"{error:#}"
|
||
);
|
||
// 另一用例从独立 seed 开始生成无环链,只用来检验表面嵌套深度上限。
|
||
let mut source = String::from("seed=Button(\"deep\")\n");
|
||
// 逐层让新 panel 包含前一 view,九层超过允许的八层,但没有引用自身或未来节点。
|
||
for index in 0..9 {
|
||
// 第一层使用 seed,后续使用前层视口名字,维持单向无环依赖。
|
||
let child = if index == 0 {
|
||
"seed".to_string()
|
||
} else {
|
||
// 引用已构造的前层,避免用循环错误遮蔽真正的深度限制。
|
||
format!("view{}", index - 1)
|
||
};
|
||
// 追加一层固定 UI 分辨率的 Panel3D 与对应 Viewport3D,层数由 index 精确控制。
|
||
source.push_str(&format!("panel{index}=Panel3D(2,2,{child})\npanel{index}.uiWidth=100\npanel{index}.uiHeight=100\nview{index}=Viewport3D(100,100,Scene3D(panel{index}))\n"));
|
||
}
|
||
// 仅把最外层 view8 放入页面,加载遍历才能真实经过全部九层。
|
||
source.push_str("page=Page()\npage.render(view8)\n");
|
||
// 要求第二程序失败且诊断为 nesting exceeds 8,与前一循环诊断区分。
|
||
let error = ReplSession::load(&source, "app.main", 317, 1)
|
||
.err()
|
||
.unwrap();
|
||
assert!(
|
||
format!("{error:#}").contains("3D UI viewport nesting exceeds 8"),
|
||
"{error:#}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 同一按钮被两个世界面板共享,两个面板指定不同 UI 分辨率。
|
||
/// 两个 Mount 的宽高分别服从各自纹理,投影中心位置及命中 ID 也独立,最终整帧仍可软件绘制。
|
||
fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() {
|
||
// 两个世界面板共享同一 Button,但各自纹理分辨率为 100×80 和 200×120,业务身份不决定布局大小。
|
||
let (mut session, initial) = ReplSession::load(
|
||
r##"
|
||
shared=Button("shared UI")
|
||
left=Panel3D(2,2,shared)
|
||
left.x=-1.2
|
||
left.uiWidth=100
|
||
left.uiHeight=80
|
||
right=Panel3D(2,2,shared)
|
||
right.x=1.2
|
||
right.uiWidth=200
|
||
right.uiHeight=120
|
||
view=Viewport3D(400,240,Scene3D(left,right))
|
||
view.camera.z=6
|
||
page=Page()
|
||
page.render(view)
|
||
"##,
|
||
"app.main",
|
||
318,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 验证文档有效,再按出现索引核对共享对象的两个 Mount 及其独立宽高。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().count", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"2"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"100"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().at(1).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"200"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().at(0).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"80"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("shared.mounts().at(1).bounds().height", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"120"
|
||
);
|
||
// 从发布帧收集面板内可交互 Mount,后面用各自 panel 投影各自纹理中心。
|
||
let mounts: Vec<_> = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.filter(|mount| mount.panel.is_some() && mount.interactive)
|
||
.collect();
|
||
// 左纹理中心为 (50,40),经左面板变换得到屏幕命中点。
|
||
let first = mounts[0]
|
||
.panel
|
||
.as_ref()
|
||
.unwrap()
|
||
.project_point(crate::geometry::Point::new(50., 40.))
|
||
.unwrap();
|
||
// 右纹理中心为 (100,60),不能沿用左纹理分辨率求中心。
|
||
let second = mounts[1]
|
||
.panel
|
||
.as_ref()
|
||
.unwrap()
|
||
.project_point(crate::geometry::Point::new(100., 60.))
|
||
.unwrap();
|
||
// 先检查左右投影顺序,再核对各屏幕点命中各自 Mount,防止共享对象把投影上下文合并。
|
||
assert!(first.x < second.x);
|
||
assert_eq!(initial.frame.hit_test(first).unwrap().id, mounts[0].id);
|
||
assert_eq!(initial.frame.hit_test(second).unwrap().id, mounts[1].id);
|
||
// 整帧输出覆盖两面板,最终 render 成功检查不同纹理尺寸可同时进入软件绘制。
|
||
let mut pixels = vec![0; 400 * 240];
|
||
// 使用真实完整显示列表光栅化;本用例检查渲染可完成,不对这里未采样的像素颜色作结论。
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&initial.display_list(1.).unwrap(),
|
||
&mut pixels,
|
||
400,
|
||
240,
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
#[test]
|
||
/// 以实际有限窗口约束加载世界 UI 示例,重复相同视口/任务轮询应保持空闲。
|
||
/// 检查面板内二维视口确实存在,执行 turn/fit/reset 并比较业务相机值,最后尝试完整光栅化。
|
||
fn world_ui_example_runs_with_window_constraints_and_programmable_actions() {
|
||
// 以实际 1024×800 有限窗口加载 world-ui 示例,避免只在无界默认布局中验证可执行性。
|
||
let (mut session, document) = ReplSession::load_with_viewport(
|
||
include_str!("../../../examples/world-ui.endor"),
|
||
"app.main",
|
||
319,
|
||
1,
|
||
Some(crate::layout::Size {
|
||
width: 1024.,
|
||
height: 800.,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 先验证初始完整文档,再模拟重复原生尺寸通知和空闲任务轮询。
|
||
document.validate().unwrap();
|
||
// 更新表与加载视口完全相同,相同尺寸不应让示例持续刷新。
|
||
let viewports = std::collections::BTreeMap::from([(
|
||
crate::window::MAIN_WINDOW,
|
||
crate::layout::Size {
|
||
width: 1024.,
|
||
height: 800.,
|
||
},
|
||
)]);
|
||
// 重复三轮验证空闲稳定性;每轮 resize 与 poll 都应返回 None。
|
||
for _ in 0..3 {
|
||
// 相同视口必须保持空闲,后续任务轮询也不能凭空产生文档。
|
||
assert!(
|
||
session
|
||
.resize_windows_incremental(&viewports, true)
|
||
.unwrap()
|
||
.is_none(),
|
||
"unchanged viewport must stay idle"
|
||
);
|
||
assert!(
|
||
session
|
||
.poll_tasks_incremental(&viewports)
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
}
|
||
// 首帧必须实际包含面板内 CanvasViewport,防止示例加载了近似空页面而误通过。
|
||
assert!(
|
||
document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.any(|mount| mount.panel.is_some() && mount.canvas_viewport)
|
||
);
|
||
// 依次执行示例 turn/fit/reset 业务动作,要求最终有有效文档,并检查 reset 后相机/面板状态。
|
||
session
|
||
.execute("actions.turn()\nactions.fit()\nactions.reset()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap()
|
||
.validate()
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("panel.rotationY", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"30"
|
||
);
|
||
assert_eq!(
|
||
session.execute("view.camera.z", "app.main").unwrap().value,
|
||
"7"
|
||
);
|
||
// 最后绘制保存的初始 document;这里只验证示例初始帧可光栅化,动作后的状态由上方查询核对。
|
||
let mut pixels = vec![0; 1024 * 800];
|
||
// 输出与加载逻辑尺寸一致的 1x 软件帧,覆盖示例多层 UI/三维命令组合。
|
||
crate::software_renderer::SoftwareRenderer::render(
|
||
&document.display_list(1.).unwrap(),
|
||
&mut pixels,
|
||
1024,
|
||
800,
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
#[test]
|
||
/// 同一段落进入纵向/横向滚动容器,禁止轴的最大范围与偏移必须为 0。
|
||
/// 允许轴保留请求偏移,文字约束按滚动轴不同而独立换行;改策略后原横向偏移应立即清零。
|
||
fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() {
|
||
// 加载共享 paragraph 分别进入纵向、横向 ScrollBlock 的示例,测量约束由各自允许轴决定。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/scroll-axis.endor"),
|
||
"app.main",
|
||
260,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 先确认首帧合法,随后检查禁止方向的最大滚动范围本来就是零。
|
||
initial.validate().unwrap();
|
||
// 纵向容器不得产生横向范围,横向容器不得产生纵向范围。
|
||
for expression in [
|
||
"vertical.mounts().at(0).scrollPosition().maxX",
|
||
"horizontal.mounts().at(0).scrollPosition().maxY",
|
||
] {
|
||
// 逐项查询源滚动状态,避免只检查 UI 看起来没有位移。
|
||
assert_eq!(session.execute(expression, "app.main").unwrap().value, "0");
|
||
}
|
||
// 两容器都请求双轴位移,让后续断言检查禁止轴被夹为零、允许轴保留请求值。
|
||
session
|
||
.execute(
|
||
"vertical.mounts().at(0).scrollTo(100, 60)\nhorizontal.mounts().at(0).scrollTo(60, 100)",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 分别核对两处偏移:vertical=(0,60)、horizontal=(60,0),不能共享一组滚动状态。
|
||
for (expression, expected) in [
|
||
("vertical.mounts().at(0).scrollPosition().x", "0"),
|
||
("vertical.mounts().at(0).scrollPosition().y", "60"),
|
||
("horizontal.mounts().at(0).scrollPosition().x", "60"),
|
||
("horizontal.mounts().at(0).scrollPosition().y", "0"),
|
||
] {
|
||
// 检查各轴投影更新后的实际状态,而非仅接受 scrollTo 调用成功。
|
||
assert_eq!(
|
||
session.execute(expression, "app.main").unwrap().value,
|
||
expected
|
||
);
|
||
}
|
||
// 纵向滚动需要在有限横向空间内换行,记录第一个出现的最终文字宽度。
|
||
let vertical_width: f32 = session
|
||
.execute("paragraph.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse()
|
||
.unwrap();
|
||
// 横向允许内容展开,第二个出现可比纵向更宽;共享业务文字仍需按各父约束独立测量。
|
||
let horizontal_width: f32 = session
|
||
.execute("paragraph.mounts().at(1).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse()
|
||
.unwrap();
|
||
// 核对有限宽约束及两个出现的差异,随后改 axis 检查已有偏移随策略更新。
|
||
assert!(vertical_width <= 244.0);
|
||
assert!(horizontal_width > vertical_width);
|
||
// 把原横向容器改为纵向后,已存在的 x=60 必须立即夹回零。
|
||
session
|
||
.execute("horizontal.axis = \"vertical\"", "app.main")
|
||
.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("horizontal.mounts().at(0).scrollPosition().x", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 未指定 axis 的 ScrollBlock 默认为 both。
|
||
/// 把已挂载容器策略写为 diagonal 必须给出允许集合诊断,不能默认为某个方向。
|
||
fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() {
|
||
// 新会话中直接构造未挂载 ScrollBlock,用于核对无需布局即可读取的默认 axis。
|
||
let mut session = ReplSession::new(262).unwrap();
|
||
// 不传 axis,构造后应公开默认 both,不能依赖首次 render 才补齐默认字段。
|
||
session
|
||
.execute(
|
||
"scroller = ScrollBlock(20, 10, TextBlock(\"default\"))",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
session.execute("scroller.axis", "app.main").unwrap().value,
|
||
"\"both\""
|
||
);
|
||
// 独立加载把 axis 写为 diagonal 的已渲染容器,验证布局读取策略时明确拒绝未知值。
|
||
let error = ReplSession::load(
|
||
r#"
|
||
scroller = ScrollBlock(20, 10, TextBlock("bad"))
|
||
scroller.axis = "diagonal"
|
||
page = Page()
|
||
page.render(scroller)
|
||
"#,
|
||
"app.main",
|
||
263,
|
||
1,
|
||
)
|
||
.err()
|
||
.expect("invalid axis");
|
||
// 诊断应列出 both/vertical/horizontal 允许集合,不得静默退回默认策略。
|
||
assert!(
|
||
error
|
||
.to_string()
|
||
.contains("ScrollBlock.axis expects both, vertical or horizontal"),
|
||
"{error:#}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 源 arrange 把构造宽度 300 的纵向 scroller 改分配为 120。
|
||
/// 文字实际宽度需扣两侧 padding 得到至多 100,横向滚动范围为零,不能继续按构造参数测量。
|
||
fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() {
|
||
// 源 Allocator.arrange 把 scroller 的实际分配改为 120,与构造宽 300 刻意不同以暴露旧约束复用。
|
||
let source = r#"
|
||
class Allocator: Column {
|
||
function arrange(self: Allocator, mount: Mount, geometry: Bounds): Arrangement {
|
||
result = Column.arrange(self, mount, geometry)
|
||
result.children.at(0).width = 120
|
||
return result
|
||
}
|
||
}
|
||
text = TextBlock("This text must wrap inside the allocated viewport, not the original constructor width. This text must wrap inside the allocated viewport.")
|
||
scroller = ScrollBlock(300, 80, text)
|
||
scroller.padding = 10
|
||
scroller.axis = "vertical"
|
||
page = Page()
|
||
page.render(Allocator(0, 0, scroller))
|
||
"#;
|
||
// 正常加载应完成分配后的重测,发布前让文字按实际视口宽度换行。
|
||
let (mut session, initial) = ReplSession::load(source, "app.main", 261, 1).unwrap();
|
||
// 先验证外层 scroller 宽确为 120,再检查内文字宽不超过扣 padding 后的 100。
|
||
initial.validate().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("scroller.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"120"
|
||
);
|
||
// 从文字的具体 Mount 读最终宽度,避免只看到 scroller 改宽却漏掉内容未重测。
|
||
let width: f32 = session
|
||
.execute("text.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse()
|
||
.unwrap();
|
||
assert!(width <= 100.0);
|
||
// axis=vertical 必须同时保持 maxX=0,不能因为内容仍按构造宽度展开而产生横向范围。
|
||
assert_eq!(
|
||
session
|
||
.execute("scroller.mounts().at(0).scrollPosition().maxX", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"0"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 嵌套两个 ScrollBlock,并让其中部分内容使用源 paint。
|
||
/// 对末尾按钮 scrollIntoView 要联动内外偏移 128/148;绘制 clip 与新命中几何必须使用同一滚动投影。
|
||
fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() {
|
||
// 独立会话构造两层滚动容器,源 Painted 委托原生 paint,以检验源命令也共享同一裁剪投影。
|
||
let mut session = ReplSession::new(241).unwrap();
|
||
// 首帧即应有非空 clip 数据,避免后面的命中检查建立在没有裁剪的近似布局上。
|
||
let result = session
|
||
.execute(
|
||
r#"
|
||
class Painted: RectBlock {
|
||
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
|
||
return RectBlock.paint(self,mount,geometry)
|
||
}
|
||
}
|
||
last = Button("last")
|
||
inner = ScrollBlock(160,100,Column(0,8,Painted(200,180),last))
|
||
outer = ScrollBlock(180,140,Column(0,8,RectBlock(180,180),inner))
|
||
page = Page()
|
||
page.render(outer)
|
||
"#,
|
||
"app.main",
|
||
)
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 至少一条绘制命令带裁剪,说明嵌套滚动上下文已进入文档。
|
||
assert!(result.frame.command_clips.iter().any(Option::is_some));
|
||
// 对最深 last 的 Mount 请求 scrollIntoView,需要联动内、外两个祖先而非只滚最近一层。
|
||
// 初始帧只检查存在裁剪项;滚动后断言两层偏移和 Mount.hitTest,没有逐命令比较新 clip 或采样像素。
|
||
session
|
||
.execute("last.mounts().at(0).scrollIntoView()", "app.main")
|
||
.unwrap();
|
||
// inner 应滚到 128、outer 滚到 148;这组偏移把 last 送入两个裁剪区域的交集。
|
||
assert_eq!(
|
||
session
|
||
.execute("inner.mounts().at(0).scrollPosition().y", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"128"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("outer.mounts().at(0).scrollPosition().y", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"148"
|
||
);
|
||
// 新屏幕点 (10,110) 应命中 last,核对偏移状态与最终输入几何一致。
|
||
assert_eq!(
|
||
session
|
||
.execute("last.mounts().at(0).hitTest(10,110)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 直接调用 Button.click 得到名义 Event,dispatch 显式事件后返回值应与输入为同一托管对象。
|
||
/// 通过底层 globals 值比较身份,避免仅比较 repr 造成不同对象也通过。
|
||
fn click_and_dispatch_return_the_actual_delivered_event() {
|
||
// 不需页面挂载即可测试源码 click/dispatch 的返回对象契约。
|
||
let mut session = ReplSession::new(237).unwrap();
|
||
// click 自动创建事件;dispatch 则接收预先保存的 dispatched,并把返回值保存在 returned。
|
||
session
|
||
.execute(
|
||
r#"button = Button("event")
|
||
clicked = button.click()
|
||
dispatched = Event("click")
|
||
returned = button.dispatch(dispatched)"#,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 先验证 click 返回名义 Event 且 kind=click,再检查显式 dispatch 的对象身份。
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.isInstance(clicked, Event)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
assert_eq!(
|
||
session.execute("clicked.kind", "app.main").unwrap().value,
|
||
"\"click\""
|
||
);
|
||
// 直接读 VM 全局字典里的 Value,比 repr 更严格地比较 returned 与 dispatched 是否为同一托管对象。
|
||
let globals = session.candidate.globals.unwrap();
|
||
assert_eq!(
|
||
session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("returned".into()))
|
||
.unwrap(),
|
||
session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("dispatched".into()))
|
||
.unwrap()
|
||
);
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
/// 测试用原生转发器:保留公开签名,要求 VM 继续调用 target,用于模拟相同函数身份下实现变化。
|
||
struct VisualRedirect {
|
||
// 下方方法替换测试指定的源实现;ContextNativeFunction 的 roots 另负责保活。
|
||
target: ObjectRef,
|
||
// 保存调用方提供的契约副本;paint 替换测试沿用原签名,onEvent 测试还会显式改 effect。
|
||
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 在其正常调用栈/预算中处理参数和结果。
|
||
fn invoke(
|
||
&self,
|
||
_: &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(),
|
||
})
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 从同一 VM 的第二窗口 desktop 查到 Block.measure,检查原生快速路径认的是方法与注册表组合。
|
||
/// 同函数换成 paint 名称或外来 types 表都不能被认作 intrinsic,避免只凭对象形状或方法名跳过源执行。
|
||
fn native_visual_identity_accepts_shared_window_registries_only() {
|
||
// 第二窗口有独立 desktop,但共享同一 types 字典;验证这种共享注册身份仍可使用原生快速路径。
|
||
let mut session = ReplSession::new(238).unwrap();
|
||
// 真实 Window 构造注册第二窗口,不手工拼造一个看似相同的 desktop。
|
||
session.execute("second = Window()", "app.main").unwrap();
|
||
// 从全局取得第二窗口,沿其 ManagedWindow payload 找到实际所属 desktop。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 必须为真实 Window 对象,保证后面的注册表比较来自正常窗口生命周期。
|
||
let Value::Object(window) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("second".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("window")
|
||
};
|
||
// 后续只操作候选 VM,统一使用它的对象存储来查询类型与原生方法身份。
|
||
let vm = &mut session.candidate.vm;
|
||
// 第二窗口记录的 desktop 是合法共享范围,不能用窗口对象自身替代注册表 owner。
|
||
let desktop = vm
|
||
.payload::<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();
|
||
}
|
||
|
||
#[test]
|
||
/// 替换 Block.measure 为源方法后既有实例应按新方法得到宽度 140。
|
||
/// 再保持原 paint 对象身份、只替换 native payload 为转发器,刷新也必须识别实现改变并产生空命令。
|
||
fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() {
|
||
// 加载原生矩形和备用源 Hooks;后面在已有实例上替换基类方法,验证运行时协议缓存失效。
|
||
let (mut session, _) = ReplSession::load(r#"
|
||
class Hooks {
|
||
function measure(self: Object, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(140, 70) }
|
||
function paint(self: Object, mount: Mount, geometry: Bounds): DisplayList { return DisplayList(ObjectList()) }
|
||
}
|
||
base = Block
|
||
rect = RectBlock(20, 10)
|
||
page = Page()
|
||
page.render(rect)
|
||
"#, "app.main", 235, 1).unwrap();
|
||
// 名字表保存基类与 Hooks 类型,后续只提取句柄,不重新执行类定义。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 本地查询闭包要求所有目标均为对象;它只读全局字典,不建立新业务实例。
|
||
let get = |name: &str| match session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text(name.into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
{
|
||
// 正常分支返回已有对象身份,供下面 define_method/replace_payload 操作。
|
||
Value::Object(value) => value,
|
||
// 夹具缺少对象时立即失败,不能将非对象值当作方法句柄继续。
|
||
_ => panic!("object"),
|
||
};
|
||
// 原 Block 基类是方法替换目标,rect 已经使用过它的原生视觉协议。
|
||
let base = get("base");
|
||
// 备用 Hooks 提供 width=140 的 measure 和返回空列表的 paint 两个源实现。
|
||
let hooks = get("Hooks");
|
||
// 取待安装的源 measure 函数,类型不符表示夹具破坏。
|
||
let Value::Object(measure) = session.candidate.vm.get_attr(hooks, "measure").unwrap() else {
|
||
panic!("measure")
|
||
};
|
||
// 保留备用源 paint,稍后通过 native 转发器调用而不是直接替换函数引用。
|
||
let Value::Object(paint) = session.candidate.vm.get_attr(hooks, "paint").unwrap() else {
|
||
panic!("paint")
|
||
};
|
||
// 直接替换 Block.measure 定义,已有 rect 的下一次刷新也必须读取新实现。
|
||
session
|
||
.candidate
|
||
.vm
|
||
.define_method(base, "measure", measure)
|
||
.unwrap();
|
||
// 刷新消费方法变更,再从同一 rect 的 Mount 核对宽度已变为 140。
|
||
session.refresh_document().unwrap().unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("rect.mounts().at(0).bounds().width", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"140"
|
||
);
|
||
// 取得原 Block.paint 的函数对象,接下来刻意保持其 ObjectRef 不变。
|
||
let original = session
|
||
.candidate
|
||
.vm
|
||
.object_store()
|
||
.lookup_method(base, "paint")
|
||
.unwrap()
|
||
.unwrap();
|
||
// 沿用原 paint 签名,只更换 native payload,检验缓存是否跟踪实现版本而非仅句柄。
|
||
let signature = session
|
||
.candidate
|
||
.vm
|
||
.object_store()
|
||
.native_signature(original)
|
||
.unwrap()
|
||
.unwrap();
|
||
// 转发器返回 Invoke 到 Hooks.paint,roots 中的 paint 保活目标;原函数身份仍相同。
|
||
session
|
||
.candidate
|
||
.vm
|
||
.replace_payload(
|
||
original,
|
||
endor_core::ContextNativeFunction::new(
|
||
"redirect paint",
|
||
VisualRedirect {
|
||
target: paint,
|
||
signature,
|
||
},
|
||
vec![paint],
|
||
),
|
||
)
|
||
.unwrap();
|
||
// 新实现返回空 DisplayList,刷新应发布空 commands,证明没有错误复用旧 intrinsic paint。
|
||
let updated = session.refresh_document().unwrap().unwrap();
|
||
assert!(updated.frame.commands.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
/// 保持 Block.onEvent 的函数句柄不变,替换转发 payload 并把签名 effect 改为 Mutating。
|
||
/// Hooks.onEvent 本体为空且本例不派发事件,检查的是协议能力/缓存变化,不是业务副作用执行。
|
||
/// 通过 VM changes 使 RenderCache 失效后 rect 从不交互变为交互,protocol_key 也应变化,证明缓存追踪实现版本。
|
||
fn same_identity_event_payload_changes_invalidate_interactivity_cache() {
|
||
// 独立会话仅构造未挂载 rect 和 Hooks,用 RenderCache 直接观察交互协议快照。
|
||
let mut session = ReplSession::new(236).unwrap();
|
||
// Hooks.onEvent 是后续转发目标,原 Block.onEvent 则保持原函数对象身份。
|
||
session
|
||
.execute(
|
||
r#"
|
||
class Hooks { function onEvent(self: Object, event: Event): Unit {} }
|
||
base = Block
|
||
rect = RectBlock(20, 10)
|
||
"#,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 从同一全局表取基类、备用 Hooks 与待检查矩形。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 只读对象查询闭包供本测试局部复用,任何非对象目标都应立即失败。
|
||
let get = |name: &str| match session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text(name.into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
{
|
||
// 正常分支保留原对象句柄,不能通过新建对象人为触发快照更新。
|
||
Value::Object(value) => value,
|
||
// 非对象意味着测试前提不满足,应直接报告而不是跳过更新路径。
|
||
_ => panic!("object"),
|
||
};
|
||
// Block 基类拥有要更换 payload 的 onEvent 方法。
|
||
let base = get("base");
|
||
// 备用源类型提供实际 onEvent 调用目标。
|
||
let hooks = get("Hooks");
|
||
// 待快照对象起初没有有效交互行为,更新后应被识别为可交互。
|
||
let rect = get("rect");
|
||
// 取备用源回调作为 VisualRedirect 的目标,后面保持引用可达。
|
||
let Value::Object(target) = session.candidate.vm.get_attr(hooks, "onEvent").unwrap() else {
|
||
panic!("onEvent")
|
||
};
|
||
// 借用候选 VM 统一执行方法查询、payload 变更与 changes 收集。
|
||
let vm = &mut session.candidate.vm;
|
||
// 取原 onEvent 函数;修改 payload 而非替换这个函数身份。
|
||
let function = vm
|
||
.object_store()
|
||
.lookup_method(base, "onEvent")
|
||
.unwrap()
|
||
.unwrap();
|
||
// 复制原 native 契约,再将 effect 标记为 Mutating,使新实现的交互能力可被协议查询识别。
|
||
let mut signature = vm
|
||
.object_store()
|
||
.native_signature(function)
|
||
.unwrap()
|
||
.unwrap();
|
||
signature.effect = endor_core::CallEffect::Mutating;
|
||
// 创建独立快照缓存,隔离会话发布流程,专门测试协议键和 VM changes 的衔接。
|
||
let mut cache = crate::snapshot::RenderCache::default();
|
||
// 首份快照应不交互,作为相同 rect 在实现变化前的基线。
|
||
let initial = cache.snapshot(vm.object_store(), rect).unwrap();
|
||
assert!(!initial.interactive);
|
||
// 丢弃构造阶段变化,只让随后 payload 替换产生的 changes 驱动失效。
|
||
vm.take_changes();
|
||
// 在原函数句柄内安装转发器,vec![target] 保活被调用的源函数。
|
||
vm.replace_payload(
|
||
function,
|
||
endor_core::ContextNativeFunction::new(
|
||
"redirect event",
|
||
VisualRedirect { target, signature },
|
||
vec![target],
|
||
),
|
||
)
|
||
.unwrap();
|
||
// 将新实现产生的变化批次送入缓存,不能靠手工清空整个 cache 掩盖依赖遗漏。
|
||
cache.invalidate(vm.take_changes());
|
||
// 对同一个 rect 重新取快照,interactive 和 protocol_key 都应反映实现版本变化。
|
||
let updated = cache.snapshot(vm.object_store(), rect).unwrap();
|
||
assert!(updated.interactive);
|
||
assert_ne!(initial.protocol_key, updated.protocol_key);
|
||
// 源侧 ui.implements 也应报告 EventTarget,核对公开协议查询与内部快照判定一致。
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.implements(base, EventTarget)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 查询简单、转发、递归、动态回调和有副作用函数的静态 call_effect。
|
||
/// 绑定方法可借助接收者识别只读性,未绑定方法保守未知;重绑被调用全局函数后结果应失效,marker 始终为 0 证明查询未执行源码。
|
||
fn source_effect_queries_use_compiled_instructions_without_running_functions() {
|
||
// 独立会话只定义待分析函数,不调用这些函数;marker 用来检测静态查询是否意外执行业务代码。
|
||
let mut session = ReplSession::new(234).unwrap();
|
||
// 夹具包含直接返回、全局转调、动态回调、自递归及状态写入;另定义可按已知接收者解析的 helper 方法。
|
||
session
|
||
.execute(
|
||
r#"
|
||
function simple(value: Number): Number { return value }
|
||
function forward(value: Number): Number { return simple(value) }
|
||
function unknown(callback: Function): Object { return callback() }
|
||
function recursive(value: Number): Number { return recursive(value) }
|
||
marker = ui.state(0)
|
||
function impure(value: Number): Number { marker.increment() return value }
|
||
original = simple
|
||
class ReadOnlyModel {
|
||
function helper(self: ReadOnlyModel, value: Number): Number { return value }
|
||
function check(self: ReadOnlyModel, value: Number): Number { return self.helper(value) }
|
||
}
|
||
model = ReadOnlyModel()
|
||
"#,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 从实际编译后的全局对象取函数句柄,分析对象存储中的指令与调用关系。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 只读直返、可解析转调和无写入递归应识别 ReadOnly;未知回调和业务写入都不能证明只读,返回 None。
|
||
for (name, expected) in [
|
||
("simple", Some(endor_core::CallEffect::ReadOnly)),
|
||
("forward", Some(endor_core::CallEffect::ReadOnly)),
|
||
("recursive", Some(endor_core::CallEffect::ReadOnly)),
|
||
("unknown", None),
|
||
("impure", None),
|
||
] {
|
||
// 每次取同一 VM 中真实源函数,类型不符即失败,不能跳过某一分析场景。
|
||
let Value::Object(function) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text(name.into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("function")
|
||
};
|
||
// 调用 call_effect 只查询效果,不执行函数;尤其递归函数没有终止条件,不应被试运行。
|
||
assert_eq!(
|
||
session
|
||
.candidate
|
||
.vm
|
||
.object_store()
|
||
.call_effect(function)
|
||
.unwrap(),
|
||
expected
|
||
);
|
||
}
|
||
// 取具体接收者,绑定方法的 self 类型与 helper 查找都由该对象提供上下文。
|
||
let Value::Object(model) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("model".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("model")
|
||
};
|
||
// bind_attr 生成捕获 model 的方法,静态分析因能解析 self.helper 而可判定只读。
|
||
let Value::Object(bound) = session.candidate.vm.bind_attr(model, "check").unwrap() else {
|
||
panic!("bound")
|
||
};
|
||
assert_eq!(
|
||
session
|
||
.candidate
|
||
.vm
|
||
.object_store()
|
||
.call_effect(bound)
|
||
.unwrap(),
|
||
Some(endor_core::CallEffect::ReadOnly)
|
||
);
|
||
// 释放 bind_attr 返回的临时拥有引用,不把分析用绑定方法长期留在 VM 根中。
|
||
session.candidate.vm.release(bound).unwrap();
|
||
// get_attr 取未绑定方法;没有具体接收者时对 self.helper 的解析应保守返回未知。
|
||
let Value::Object(unbound) = session.candidate.vm.get_attr(model, "check").unwrap() else {
|
||
panic!("method")
|
||
};
|
||
assert_eq!(session.candidate.vm.call_effect(unbound).unwrap(), None);
|
||
// 保留原 forward 函数身份,随后只重绑它在全局中读取的 simple,检验分析依赖失效。
|
||
let Value::Object(forward) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("forward".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("forward")
|
||
};
|
||
// 将被调全局名改指向写状态函数,forward 不再能被证明只读。
|
||
session.execute("simple = impure", "app.main").unwrap();
|
||
assert_eq!(session.candidate.vm.call_effect(forward).unwrap(), None);
|
||
// 恢复原绑定后应重新识别 ReadOnly,避免未知结果被永久缓存。
|
||
session.execute("simple = original", "app.main").unwrap();
|
||
assert_eq!(
|
||
session.candidate.vm.call_effect(forward).unwrap(),
|
||
Some(endor_core::CallEffect::ReadOnly)
|
||
);
|
||
// marker 必须始终为零,证明所有效果查询及失效重算都没有执行 impure 的函数体。
|
||
assert_eq!(
|
||
session.execute("marker.value", "app.main").unwrap().value,
|
||
"0"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 语言层拒绝用普通实例字段遮蔽有契约的 paint/measure/onEvent。
|
||
/// 底层绕过语言写入存储模拟方法阴影,缓存需失效至祖先并报 TraitViolation;删除阴影后可再次生成合法快照。
|
||
fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() {
|
||
// 先构造 root 包含 rect 的父子关系,用祖先快照观察子对象方法阴影是否向上使缓存失效。
|
||
let mut session = ReplSession::new(233).unwrap();
|
||
// 无需页面发布,RenderCache 将直接从业务根生成快照,隔离发布调度的影响。
|
||
session
|
||
.execute(
|
||
"rect = RectBlock(40, 20)\nroot = Column(0, 0, rect)",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 取当前业务对象句柄,不重新构造树来制造失效。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 局部只读查询闭包要求命名值是对象,供 rect/root 两次查找复用。
|
||
let lookup = |name: &str| match session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text(name.into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
{
|
||
// 正常分支返回原身份,用于比较同一棵树更新前后的缓存状态。
|
||
Value::Object(value) => value,
|
||
// 缺失对象前提应直接失败,不能继续在错误对象上测试协议。
|
||
_ => panic!("object"),
|
||
};
|
||
// 目标子项拥有受契约约束的视觉/事件方法,语言层不允许普通字段遮蔽。
|
||
let rect = lookup("rect");
|
||
// 父容器作为缓存入口,检验子项变化是否传播到已缓存祖先。
|
||
let root = lookup("root");
|
||
// 分别尝试 paint、measure、onEvent 阴影,错误必须来自 instance attribute shadows 契约保护。
|
||
for name in ["paint", "measure", "onEvent"] {
|
||
// 使用普通源赋值,先验证高层 API 在写入前就阻止非法遮蔽。
|
||
let error = session
|
||
.execute(&format!("rect.{name} = 1"), "app.main")
|
||
.err()
|
||
.expect("typed method cannot be shadowed");
|
||
assert!(
|
||
error.to_string().contains("instance attribute shadows"),
|
||
"{error:#}"
|
||
);
|
||
}
|
||
// 后续故意在原始属性存储层制造坏状态,以覆盖快照的防御性验证和缓存失效。
|
||
let vm = &mut session.candidate.vm;
|
||
// 沿属性转发链找到末尾实际存储,向它写入可绕过实例层的 typed method 阴影检查。
|
||
let storage = *vm
|
||
.object_store()
|
||
.attribute_path(rect)
|
||
.unwrap()
|
||
.last()
|
||
.unwrap();
|
||
// 独立缓存先生成 root 快照,第二次无变化访问应完全命中。
|
||
let mut cache = crate::snapshot::RenderCache::default();
|
||
cache.snapshot(vm.object_store(), root).unwrap();
|
||
// 丢弃构造阶段变化;后面的变化批次应只来自人为注入的阴影。
|
||
vm.take_changes();
|
||
cache.snapshot(vm.object_store(), root).unwrap();
|
||
// visited=0 确认已有命中缓存,后续错误检查才真正覆盖了缓存失效。
|
||
assert_eq!(cache.visited, 0);
|
||
// 底层存储注入 paint=nil,消费 VM changes 后从 root 快照必须看到子项 TraitViolation。
|
||
vm.set_attr(storage, "paint", Value::Nil).unwrap();
|
||
cache.invalidate(vm.take_changes());
|
||
assert!(matches!(
|
||
cache.snapshot(vm.object_store(), root),
|
||
Err(endor_core::VmError::TraitViolation { .. })
|
||
));
|
||
// 删除阴影并再次送入变化批次;合法祖先快照应恢复且有节点被重新访问。
|
||
vm.del_attr(storage, "paint").unwrap();
|
||
cache.invalidate(vm.take_changes());
|
||
cache.snapshot(vm.object_store(), root).unwrap();
|
||
assert!(cache.visited > 0);
|
||
}
|
||
|
||
#[test]
|
||
/// 为参数/返回契约不兼容的 present 方法声明 Presentable,注册必须失败且方法体 marker 不执行。
|
||
/// 参数更宽、返回更具体的合法方法可注册,并实际产出 TextBlock,运行时保持健康。
|
||
fn presentable_type_contracts_reject_before_registration_or_body_effects() {
|
||
// 独立会话用 marker 观测注册过程是否误执行 present 方法体。
|
||
let mut session = ReplSession::new(232).unwrap();
|
||
// 先创建计数状态,再让不兼容的方法体尝试递增;注册失败时计数必须不变。
|
||
session.execute("marker = ui.state(0)", "app.main").unwrap();
|
||
// 两类错误分别缩窄 window 参数为 Point 和把返回改为 Number,均不满足 Presentable 方法契约。
|
||
for (name, method) in [
|
||
(
|
||
"WrongParameter",
|
||
"function present(self: WrongParameter, window: Point): Block { marker.increment() return TextBlock(\"bad\") }",
|
||
),
|
||
(
|
||
"WrongResult",
|
||
"function present(self: WrongResult, window: Window): Number { marker.increment() return 7 }",
|
||
),
|
||
] {
|
||
// 每轮定义独立类型并显式注册,保持“方法存在但契约不符”的错误前提。
|
||
let input = format!("class {name} {{ {method} }}\nui.implement({name}, Presentable)");
|
||
// 注册必须在调用方法体前失败;诊断应精确指出 Presentable.present。
|
||
let error = session
|
||
.execute(&input, "app.main")
|
||
.err()
|
||
.expect("incompatible typed capability");
|
||
assert!(
|
||
error
|
||
.to_string()
|
||
.contains("trait Presentable method present"),
|
||
"{error:#}"
|
||
);
|
||
// 失败不能留下半注册能力,随后 implements 查询应为 false。
|
||
assert_eq!(
|
||
session
|
||
.execute(&format!("ui.implements({name}, Presentable)"), "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"false"
|
||
);
|
||
// 方法体没有试运行或副作用,marker 仍为零。
|
||
assert_eq!(
|
||
session.execute("marker.value", "app.main").unwrap().value,
|
||
"0"
|
||
);
|
||
}
|
||
// 合法类放宽参数为 Object、返回具体 TextBlock;显式 implement 后 ui.present 应真正调用它。
|
||
session.execute(r#"
|
||
class Good {
|
||
function present(self: Good, window: Object): TextBlock { return TextBlock("typed") }
|
||
}
|
||
ui.implement(Good, Presentable)
|
||
view = ui.present(Good())
|
||
"#, "app.main").unwrap();
|
||
// 返回值满足 TextBlock 名义类型,且前面的注册错误不应停止会话。
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.isInstance(view, TextBlock)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
assert!(!session.is_faulted());
|
||
}
|
||
|
||
#[test]
|
||
/// 加载显式 Presentable 示例,检查视图的 source 字段仍指原 Product 对象。
|
||
/// 改变 product.name 可响应式更新显示;另验证错误 Visual 覆盖在定义时拒绝,不破坏现有会话。
|
||
fn presentable_dispatch_preserves_source_identity_and_reactive_display() {
|
||
// 加载显式能力展示示例,视图包含原 Product 引用及响应式字段显示。
|
||
let (mut session, initial) = ReplSession::load(
|
||
include_str!("../../../examples/presentable.endor"),
|
||
"app.main",
|
||
1,
|
||
230,
|
||
)
|
||
.unwrap();
|
||
// 先确认自定义展示已生成预期文字,排除走默认回退却误通过身份检查。
|
||
assert!(initial.frame.commands.iter().any(|command| matches!(command,RenderCommand::Text { value, .. } if value == "普通对象的显式展示能力")));
|
||
// 从同一名字表取原业务 product 和生成的视图,直接比较 VM 对象身份。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 原 Product 是展示能力的接收者,后续 view.source 必须指回它。
|
||
let Value::Object(product) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("product".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("expected Product");
|
||
};
|
||
// 视图是可挂载的 Block,不能把复制出来的展示数据当作业务源对象。
|
||
let Value::Object(view) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("view".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("expected Block");
|
||
};
|
||
// source 字段需保存同一 ObjectRef;另用 supports 检查公开能力判定。
|
||
assert_eq!(
|
||
session.candidate.vm.get_attr(view, "source").unwrap(),
|
||
Value::Object(product)
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.supports(product,Presentable)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"true"
|
||
);
|
||
// 只改原 product.name,已建立的显示依赖应发布 updated 文字,不重新调用页面构造。
|
||
let changed = session
|
||
.execute("product.name = \"updated\"", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(
|
||
changed.frame.commands.iter().any(
|
||
|command| matches!(command,RenderCommand::Text { value, .. } if value == "updated")
|
||
)
|
||
);
|
||
// 错误 Visual 覆写缺少必要参数,类定义时应拒绝,既有健康会话仍可继续。
|
||
assert!(session.execute("class Bad: RectBlock { function paint(self: Bad): DisplayList { return DisplayList(ObjectList()) } }","app.main").err().unwrap().to_string().contains("trait Visual method paint"));
|
||
assert!(!session.is_faulted());
|
||
}
|
||
|
||
#[test]
|
||
/// 仅有同名且签名匹配的 present 方法不自动声明能力,ui.present 走回退且 marker 不增。
|
||
/// 显式 implement 后 ui.open 才执行自定义方法;缺失实现的注册错误不改变已发布文档。
|
||
fn matching_present_method_does_not_implicitly_declare_presentable() {
|
||
// 新会话定义签名看似匹配的 present,但不注册能力,检测是否存在隐式鸭子类型调用。
|
||
let mut session = ReplSession::new(231).unwrap();
|
||
// ui.present(data) 应使用回退;自定义方法若被误调用会使 marker 增加。
|
||
session
|
||
.execute(
|
||
r#"
|
||
class Data {
|
||
marker: Object
|
||
function present(self: Data, window: Window): Block {
|
||
self.marker.increment()
|
||
return TextBlock("custom")
|
||
}
|
||
}
|
||
marker = ui.state(0)
|
||
data = Data(marker)
|
||
fallback = ui.present(data)
|
||
"#,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 回退不执行自定义方法,marker=0 且 supports=false,区分方法存在与能力声明。
|
||
assert_eq!(
|
||
session.execute("marker.value", "app.main").unwrap().value,
|
||
"0"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.supports(data,Presentable)", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"false"
|
||
);
|
||
// 显式注册后用 ui.open(data) 走实际展示路径,方法应恰好执行一次。
|
||
session
|
||
.execute(
|
||
"ui.implement(Data,Presentable)\ncustom = ui.open(data)",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
session.execute("marker.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
// 保存已发布文档集合,接下来注册缺少 present 的类型必须失败且不替换现有帧。
|
||
let before = session.documents.clone();
|
||
// Missing 没有实现能力方法,注册失败属于可恢复输入错误,不能发布半成品页面。
|
||
assert!(
|
||
session
|
||
.execute(
|
||
"class Missing {}\nui.implement(Missing,Presentable)",
|
||
"app.main"
|
||
)
|
||
.is_err()
|
||
);
|
||
assert_eq!(
|
||
session.documents[&crate::window::MAIN_WINDOW]
|
||
.frame
|
||
.layout_version,
|
||
before[&crate::window::MAIN_WINDOW].frame.layout_version
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 隐藏 rect 的 width 绑定先随模型更新,挂载到两个窗口后同一业务值更新两个窗口几何。
|
||
/// 断开令牌后模型变化不再传递,直接写目标属性仍正常发布。
|
||
fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() {
|
||
// 先在未挂载 rect 上建立 width 绑定,验证绑定求值不依赖可见 Mount。
|
||
let mut session = ReplSession::new(220).unwrap();
|
||
// model.read 读取 width 并驱动 rect.width,token 保留连接用于后续断开。
|
||
session
|
||
.execute(
|
||
r##"
|
||
class Model {
|
||
width: Number
|
||
function read(self: Model): Number { return self.width }
|
||
}
|
||
model = Model(80)
|
||
rect = RectBlock(1,20,0,"#DDEEFF")
|
||
token = rect.bind("width",model.read)
|
||
"##,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
session.execute("rect.width", "app.main").unwrap().value,
|
||
"80"
|
||
);
|
||
// 隐藏对象也必须跟随依赖字段写入,从 80 更新为 140。
|
||
session.execute("model.width = 140", "app.main").unwrap();
|
||
assert_eq!(
|
||
session.execute("rect.width", "app.main").unwrap().value,
|
||
"140"
|
||
);
|
||
// 将同一 rect 挂到两个窗口,之后一次业务 width 更新应传播到两份出现几何。
|
||
session.execute("first = Page()\nfirst.render(rect)\nother = Window()\nsecond = Page()\nsecond.open(other)\nsecond.render(rect)", "app.main").unwrap();
|
||
// 把模型改为 260,正常刷新返回主窗口及其他窗口的新文档。
|
||
let updated = session
|
||
.execute("model.width = 260", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 把主 frame 与 windows 中各帧统一检查,每个窗口都应出现宽 260 的目标。
|
||
for frame in std::iter::once(&updated.frame)
|
||
.chain(updated.windows.values().map(|document| &document.frame))
|
||
{
|
||
// 逐帧验证实际几何,不能只读共享字段就推断另一窗口已刷新。
|
||
assert!(frame.mounts.iter().any(|mount| mount.width == 260.0));
|
||
}
|
||
// 断开后再改模型为 360,目标应留在 260,证明订阅写入已停止。
|
||
session
|
||
.execute("token.disconnect()\nmodel.width = 360", "app.main")
|
||
.unwrap();
|
||
assert_eq!(
|
||
session.execute("rect.width", "app.main").unwrap().value,
|
||
"260"
|
||
);
|
||
// 直接给目标 width=180 仍应正常发布;断开绑定不能把目标属性变成不可写。
|
||
let direct = session
|
||
.execute("rect.width = 180", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(direct.frame.mounts.iter().any(|mount| mount.width == 180.0));
|
||
}
|
||
|
||
#[test]
|
||
/// 同一目标两个属性各绑定一个返回新 Record 的计算。
|
||
/// 二者都应稳定为 value=7,不能因对象输出写入更新目标版本而相互触发无穷重算。
|
||
fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() {
|
||
// 独立会话把两个不同属性绑定到同一个创建新 Record 的计算,检验输出对象变化的精确依赖。
|
||
let mut session = ReplSession::new(222).unwrap();
|
||
// 每次 makeRecord 都分配新身份,若绑定误读整个目标版本,两项会因对方写入而无穷重算。
|
||
session
|
||
.execute(
|
||
r##"
|
||
class Record { value: Number }
|
||
function makeRecord(): Object { return Record(7) }
|
||
rect = RectBlock(1,20)
|
||
rect.first = Record(0)
|
||
rect.second = Record(0)
|
||
first = rect.bind("first",makeRecord)
|
||
second = rect.bind("second",makeRecord)
|
||
"##,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 两项都应收敛到 value=7 且 VM 健康;不能只保证其中一个绑定最终被写入。
|
||
assert_eq!(
|
||
session
|
||
.execute("rect.first.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"7"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("rect.second.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"7"
|
||
);
|
||
assert!(!session.is_faulted());
|
||
}
|
||
|
||
#[test]
|
||
/// 可见和隐藏 rect 都绑定到读取全局 size 的函数,用 bindingEvaluations 比较精确失效。
|
||
/// 无关 REPL 结果不重算,size 改变只各算一次;直接破坏一个输出仅重算该绑定并恢复正确值。
|
||
fn property_bindings_use_precise_names_and_repair_direct_output_changes() {
|
||
// 可见 rect 与隐藏 hidden 都读取全局 size,同一目标名依赖必须在两种可见性下生效。
|
||
let (mut session, _) = ReplSession::load(
|
||
r#"
|
||
size = 80
|
||
function readWidth(): Number { return size }
|
||
rect = RectBlock(1,20)
|
||
hidden = RectBlock(1,20)
|
||
rect.bind("width",readWidth)
|
||
hidden.bind("width",readWidth)
|
||
page = Page()
|
||
page.render(rect)
|
||
"#,
|
||
"app.main",
|
||
903,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 查询累计 bindingEvaluations 并转整数;该只读统计查询本身不应让绑定重新求值。
|
||
let count = |session: &mut ReplSession| {
|
||
session
|
||
.execute("ui.stats().bindingEvaluations", "app.main")
|
||
.unwrap()
|
||
.value
|
||
.parse::<usize>()
|
||
.unwrap()
|
||
};
|
||
// 初次两项各求值一次,记录基线后用无关表达式检查按全局名字精确失效。
|
||
let baseline = count(&mut session);
|
||
assert_eq!(baseline, 2);
|
||
// 普通 42 结果不改 size,也不应发布或增加求值数。
|
||
assert!(
|
||
session
|
||
.execute("42", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_none()
|
||
);
|
||
assert_eq!(count(&mut session), baseline);
|
||
// 改变被读取的全局名,两项应各重算一次,包括未挂载 hidden。
|
||
session.execute("size = 120", "app.main").unwrap();
|
||
assert_eq!(count(&mut session), baseline + 2);
|
||
assert_eq!(
|
||
session.execute("hidden.width", "app.main").unwrap().value,
|
||
"120"
|
||
);
|
||
// 直接破坏一个绑定输出,只需重算 rect 这一项并恢复 120,不能把 hidden 一起误失效。
|
||
session.execute("rect.width = 200", "app.main").unwrap();
|
||
assert_eq!(count(&mut session), baseline + 3);
|
||
assert_eq!(
|
||
session.execute("rect.width", "app.main").unwrap().value,
|
||
"120"
|
||
);
|
||
assert_eq!(count(&mut session), baseline + 3);
|
||
}
|
||
|
||
#[test]
|
||
/// 加载 GC 示例后建立 Page/Block/等待协程之间的循环,先确认任务真的停在 await。
|
||
/// 关闭页面后任务仍可独立存活;释放业务引用后对象数回基线、弱 Page/Block 失效,证明渲染缓存未额外持根。
|
||
fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() {
|
||
// 真实 GC 示例中 Page、CycleRect 与 await 任务形成环,measure/paint 也读取环中的 Page 字段。
|
||
let (mut session, _) = ReplSession::load(
|
||
include_str!("../../../examples/gc-lifecycle.endor"),
|
||
"app.main",
|
||
902,
|
||
1,
|
||
)
|
||
.unwrap();
|
||
// 在临时环尚未建立时记录对象数,最终释放应回到同一业务基线。
|
||
let baseline = session
|
||
.execute("ui.stats().objects", "app.main")
|
||
.unwrap()
|
||
.value;
|
||
// 创建临时页并启动协程;随后 poll 推进到 sleep await,确保存在等待帧而非仅有排队任务。
|
||
session.execute("mountCycle()", "app.main").unwrap();
|
||
session.poll_tasks().unwrap();
|
||
// phase=1 且任务 running 证明源码已执行到 await,尚未恢复到后续 phase=2。
|
||
assert_eq!(
|
||
session.execute("phase.value", "app.main").unwrap().value,
|
||
"1"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.execute("pending.value.status()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"\"running\""
|
||
);
|
||
// 取 holder 中保留的临时页面,创建弱观察句柄而不添加新的持久根。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// holder 是外部状态容器,value 指向要释放的临时 Page。
|
||
let Value::Object(holder) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("holder".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("holder")
|
||
};
|
||
// 取 Page 原身份,关闭与清理后应无法通过弱句柄升级。
|
||
let Value::Object(page) = session.candidate.vm.get_attr(holder, "value").unwrap() else {
|
||
panic!("page")
|
||
};
|
||
// 解析页面内容树的第一个 CycleRect,同时观察页面与子项是否都能回收。
|
||
let rect = crate::block::resolve(session.candidate.vm.object_store(), page)
|
||
.unwrap()
|
||
.1
|
||
.children[0]
|
||
.block;
|
||
// 页面弱引用仅作检查,不应成为回收根。
|
||
let page_weak = session.candidate.vm.object_store().downgrade(page).unwrap();
|
||
// 子项也用弱引用检查,防止只回收页面而缓存仍意外保留业务 Block。
|
||
let rect_weak = session.candidate.vm.object_store().downgrade(rect).unwrap();
|
||
// 仅关闭展示不等于释放业务引用,pending 仍 running;不能把这种存活误判为缓存泄漏。
|
||
session.execute("holder.value.close()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("pending.value.status()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"\"running\""
|
||
);
|
||
// 释放 holder/pending 的外部引用后,再检查对象数恢复且两个弱句柄均失效。
|
||
session.execute("releaseCycle()", "app.main").unwrap();
|
||
assert_eq!(
|
||
session
|
||
.execute("ui.stats().objects", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
baseline
|
||
);
|
||
assert!(session.candidate.vm.upgrade(page_weak).unwrap().is_none());
|
||
assert!(session.candidate.vm.upgrade(rect_weak).unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// 仅保留绑定 token,删除隐藏目标并回收,目标弱句柄应失效且 token 断开。
|
||
/// 另用会写模型的计算函数创建绑定,写入前只读保护失败并停止 VM,原 width 仍为 80。
|
||
fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() {
|
||
// 同一会话先测试 token 对隐藏目标的弱持有,再测试计算函数的只读约束。
|
||
let mut session = ReplSession::new(221).unwrap();
|
||
// model.read 是合法计算,model.bad 则试写 width=999,后者只在第二阶段被绑定调用。
|
||
session
|
||
.execute(
|
||
r##"
|
||
class Model {
|
||
width: Number
|
||
function read(self: Model): Number { return self.width }
|
||
function bad(self: Model): Number {
|
||
self.width = 999
|
||
return self.width
|
||
}
|
||
}
|
||
model = Model(80)
|
||
rect = RectBlock(1,20,0,"#DDEEFF")
|
||
token = rect.bind("width",model.read)
|
||
"##,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 保留名字表以删除 rect 的强全局引用,同时继续保留 token 和 model。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 取得原隐藏 rect 的真实身份,后续不能用另建目标代替 GC 观察。
|
||
let Value::Object(target) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("rect".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("expected Block");
|
||
};
|
||
// 弱句柄用于证明目标真正消失,token 不能反向强持有该对象。
|
||
let weak = session
|
||
.candidate
|
||
.vm
|
||
.object_store()
|
||
.downgrade(target)
|
||
.unwrap();
|
||
// 删除 rect 的全局根,随后显式循环回收,token 仍留在 globals。
|
||
session
|
||
.candidate
|
||
.vm
|
||
.dict_delete(globals, &Value::Text("rect".into()))
|
||
.unwrap();
|
||
// 回收后目标弱升级失败且 token.isConnected=false,验证弱连接清理。
|
||
session.candidate.vm.collect_cycles();
|
||
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
|
||
assert_eq!(
|
||
session
|
||
.execute("token.isConnected()", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"false"
|
||
);
|
||
// 另建目标绑定 model.bad,计算开始时必须处于只读范围,业务写入应在发生前被拦截。
|
||
assert!(
|
||
session
|
||
.execute(
|
||
"other = RectBlock(1,20)\nbadToken = other.bind(\"width\",model.bad)",
|
||
"app.main"
|
||
)
|
||
.is_err()
|
||
);
|
||
assert!(session.is_faulted());
|
||
// 会话 faulted 后直接读取 VM 的 model;不能用普通 execute 查询遮蔽原值检查。
|
||
let Value::Object(model) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("model".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("expected model");
|
||
};
|
||
// width 仍为 80,证明错误没有先执行写入再停止后续工作。
|
||
assert_eq!(
|
||
session.candidate.vm.get_attr(model, "width").unwrap(),
|
||
Value::Number(80.into())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 两个 textOf 绑定读取同一 counter,按钮调用源辅助函数再执行原生 increment。
|
||
/// 点击后两条文字命令都必须更新为 10,覆盖跨源回调与原生写入的依赖传播。
|
||
fn native_method_callbacks_update_two_explicit_bindings() {
|
||
// 独立会话让 first/second 各自绑定同一 counter.value,测试源回调转调原生 increment 的依赖传播。
|
||
let mut session = ReplSession::new(21).unwrap();
|
||
// 初始计数为 9,按钮调用源函数 increment;两条显示文字通过不同绑定读取同一状态。
|
||
session.execute("counter = ui.state(9)\nfirst = ui.textOf(counter, \"value\")\nsecond = ui.textOf(counter, \"value\")\nbutton = ui.button(\"increment\")\nfunction increment(): Unit { counter.increment() }\nbutton.onClick(increment)\npage = ui.page()\npage.render(ui.row(0, 8, first, second, button))", "app.main").unwrap();
|
||
// 源码 click 执行回调后应发布新文档,不手工刷新文本或重新建立绑定。
|
||
let updated = session
|
||
.execute("button.click()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 按发布顺序收集文字值,前两个对应 first/second,排除后面的按钮标题。
|
||
let texts: Vec<_> = updated
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.filter_map(|command| match command {
|
||
// 文字命令借用已拥有的字符串,比较实际发布数据而非重新查询源对象。
|
||
RenderCommand::Text { value, .. } => Some(value.as_str()),
|
||
// 忽略按钮背景等非文字命令,保持两个绑定输出的相对顺序。
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(&texts[..2], &["10", "10"]);
|
||
}
|
||
|
||
#[test]
|
||
/// 保留 card 业务对象,关闭/重开页面确认隐藏内容修改仍可显示。
|
||
/// 再次关闭并删除 Page 全局绑定后回收,Page 弱句柄应失效但 card 仍存在,子项不能反向强持有页面。
|
||
fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content() {
|
||
// 页面与 card 分别保存在全局;用这种可独立持有的内容检验子项不会反向保活 Page。
|
||
let mut session = ReplSession::new(22).unwrap();
|
||
// 先正常挂载 card,建立展示根与内容关系,再开始关闭/重开流程。
|
||
session
|
||
.execute(
|
||
"card = ui.rect(10, 20)\npage = ui.page()\npage.render(card)",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// 后面只删 page 名字,刻意保留 card,以分离页面回收与共享内容存活。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 取原页面身份,弱观察对象必须是这次已挂载的 Page。
|
||
let Value::Object(page) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("page".into()))
|
||
.unwrap()
|
||
.unwrap()
|
||
else {
|
||
panic!("expected Page")
|
||
};
|
||
// 弱句柄不增加页面持久根,最终可检查闭环引用是否释放。
|
||
let weak = session.candidate.vm.downgrade(page).unwrap();
|
||
// 第一次关闭后仍保留 page 全局引用,允许修改隐藏内容并重开验证页面可复用。
|
||
session.execute("page.close()", "app.main").unwrap();
|
||
// 隐藏时改原 card;后面重开仍应正常发布,不要求重新创建内容。
|
||
session
|
||
.execute("card.setColor(\"#FFEEDD\")", "app.main")
|
||
.unwrap();
|
||
assert!(
|
||
session
|
||
.execute("page.open()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_some()
|
||
);
|
||
// 第二次关闭后删除 page 的全局根,进入真正的页面回收检查。
|
||
session.execute("page.close()", "app.main").unwrap();
|
||
// 只删除页面全局引用,card 全局引用继续保留。
|
||
session
|
||
.candidate
|
||
.vm
|
||
.dict_delete(globals, &Value::Text("page".into()))
|
||
.unwrap();
|
||
// 沿真实桥接根集合回收,Page 弱升级必须失败而 card 仍可从 globals 找到。
|
||
session
|
||
.bridge
|
||
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
|
||
.unwrap();
|
||
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
|
||
assert!(
|
||
session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("card".into()))
|
||
.unwrap()
|
||
.is_some()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 保存视觉对象 alias 并嵌入容器,给 card 赋新视觉值应沿共享引用更新嵌套出现位置且身份不变。
|
||
/// 包含自身的替换被拒绝;unmount 后更新不发布,重新 render 使用最新值。
|
||
fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() {
|
||
// card、other 与容器子项引用同一共享业务身份,检验替换如何传播到嵌套已挂载位置。
|
||
let mut session = ReplSession::new(10).unwrap();
|
||
// 先建立 alias 和嵌套树,随后只对 card 重新赋视觉值。
|
||
session.execute("card = ui.rect(10, 20)\nother = card\ncontainer = ui.column(0, 0, other)\npage = ui.page()\npage.render(container)", "app.main").unwrap();
|
||
// 保存 other 的共享身份展示值,替换后新返回值与旧 alias 都应保持该身份。
|
||
let identity = session.execute("other", "app.main").unwrap().value;
|
||
// card 改为 640×200 的矩形,业务身份保持而实际视觉存储/几何更新。
|
||
let updated = session
|
||
.execute("card = ui.rect(640, 200, 32)", "app.main")
|
||
.unwrap();
|
||
assert_eq!(updated.value, identity);
|
||
assert_eq!(
|
||
session.execute("other", "app.main").unwrap().value,
|
||
identity
|
||
);
|
||
// 从替换产生的发布文档检查嵌套绘制尺寸,不只比较 alias 的 repr。
|
||
let frame = updated.document.unwrap().frame;
|
||
assert!(matches!(
|
||
frame.commands[0],
|
||
RenderCommand::RoundedRect {
|
||
width: 640.0,
|
||
height: 200.0,
|
||
..
|
||
}
|
||
));
|
||
// 替换值若包含自身 alias 会形成组合环,必须拒绝;随后只读查询不应产生文档。
|
||
assert!(
|
||
session
|
||
.execute("card = ui.column(0, 0, other)", "app.main")
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
session
|
||
.execute("other", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_none()
|
||
);
|
||
// page.unmount 清除展示根并发布空 commands,但不删除仍在全局的共享业务对象。
|
||
let unmounted = session
|
||
.execute("page.unmount()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(unmounted.frame.commands.is_empty());
|
||
// 未挂载时更新业务值不发布画面,随后重新 render 需采用最新 80×50 几何。
|
||
assert!(
|
||
session
|
||
.execute("card = ui.rect(80, 50)", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_none()
|
||
);
|
||
// 通过旧 alias 重新挂载,证明别名转发在卸载期间也保持最新值。
|
||
let remounted = session
|
||
.execute("page.render(other)", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(matches!(
|
||
remounted.frame.commands[0],
|
||
RenderCommand::RoundedRect {
|
||
width: 80.0,
|
||
height: 50.0,
|
||
..
|
||
}
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
/// 创建文字但未挂载时只保留 VM 值,title 与最后结果 _ 都可跨输入读取。
|
||
/// 挂载后引用替换自动更新画面,成功文档保留 Console transcript;无关查询不重复发布。
|
||
fn live_session_retains_objects_and_updates_only_after_mounting() {
|
||
// 新会话先创建隐藏文字,测试跨 Console 输入的业务全局和最后结果 _ 保留。
|
||
let mut session = ReplSession::new(7).unwrap();
|
||
// 仅构造 title 不调用 render,因此应返回对象结果但没有文档。
|
||
let created = session
|
||
.execute(
|
||
r##"title = ui.text("你好,Tessera", 2, "#243047")"##,
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
// title 和 _ 都应能在后续输入读回同一结果,隐藏对象不因无画面而消失。
|
||
assert!(created.document.is_none());
|
||
assert_eq!(
|
||
session.execute("title", "app.main").unwrap().value,
|
||
created.value
|
||
);
|
||
assert_eq!(
|
||
session.execute("_", "app.main").unwrap().value,
|
||
created.value
|
||
);
|
||
// 创建 Page 并挂载已有 title,首次可见文档应显示最初中文文本。
|
||
let rendered = session
|
||
.execute("page = ui.page()\npage.render(title)", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(
|
||
matches!(&rendered.frame.commands[0], RenderCommand::Text { value, .. } if value == "你好,Tessera")
|
||
);
|
||
// 会话文档结构树是 repl-session,区别于单次完整源码编译的 script/module 根。
|
||
assert_eq!(rendered.root.as_ref().unwrap().kind, "repl-session");
|
||
// 给已挂载 title 赋新视觉值应自动发布更新,不需要显式重复 render。
|
||
assert!(
|
||
session
|
||
.execute(r#"title = ui.text("changed")"#, "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_some()
|
||
);
|
||
// 再显式 render,核对最新文字及 Console 输入历史;最后只读查询不重复发布。
|
||
let updated = session
|
||
.execute("page.render(title)", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(
|
||
matches!(&updated.frame.commands[0], RenderCommand::Text { value, .. } if value == "changed")
|
||
);
|
||
assert!(updated.source.contains("// Console input 6"));
|
||
assert!(
|
||
session
|
||
.execute("title", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.is_none()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 编译期错误不能执行同输入前面的 value=2;运行期错误则保留已经发生的 value=3。
|
||
/// 失败输入的待发布 render 被丢弃,后续显式 render 可恢复;另检查函数局部赋值正常工作。
|
||
fn live_session_compiles_before_execution_and_discards_failed_render() {
|
||
// 新会话区分编译期拒绝与运行期部分业务写入;页面发布采用独立成功边界。
|
||
let mut session = ReplSession::new(8).unwrap();
|
||
// 建立 value=1 与可用 Page,后续失败输入不能破坏已建立的全局。
|
||
session
|
||
.execute("value = 1\npage = ui.page()", "app.main")
|
||
.unwrap();
|
||
// 同次输入含未定义名称,完整编译失败时前面的 value=2 不得执行。
|
||
assert!(session.execute("value = 2\nmissing", "app.main").is_err());
|
||
assert_eq!(session.execute("value", "app.main").unwrap().value, "1");
|
||
// 另一输入先写 value=3、请求 render,再用负尺寸触发运行期错误;业务写入与待发布帧要分别处理。
|
||
assert!(
|
||
session
|
||
.execute(
|
||
"value = 3\npage.render(ui.text(\"discarded\"))\nui.rect(-1, 1, 0)",
|
||
"app.main"
|
||
)
|
||
.is_err()
|
||
);
|
||
// 失败前完成的 value=3 仍保留,但后续查询不能自动发布失败输入的 discarded 内容。
|
||
let retained = session.execute("value", "app.main").unwrap();
|
||
assert_eq!(retained.value, "3");
|
||
assert!(retained.document.is_none());
|
||
// 发布失败后 Page/绑定仍可用,但失败输入的待显示内容不能自动发布,需后续成功的显式 render。
|
||
let recovered = session
|
||
.execute("page.render(ui.text(\"recovered\"))", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(recovered.frame.commands.iter().any(|command| matches!(
|
||
command, RenderCommand::Text { value, .. } if value == "recovered"
|
||
)));
|
||
// 恢复帧只能包含成功 render 的内容,不能混入失败输入的待显示命令。
|
||
assert!(recovered.frame.commands.iter().all(|command| !matches!(
|
||
command, RenderCommand::Text { value, .. } if value == "discarded"
|
||
)));
|
||
// 最后验证函数局部赋值与重绑可正常执行,不因失败恢复路径破坏局部作用域。
|
||
session
|
||
.execute(
|
||
"function local(value: Number): Number { result = value result = 42 return result }",
|
||
"app.main",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
session.execute("local(40)", "app.main").unwrap().value,
|
||
"42"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 普通顶层 page 脚本直接经 Aster 执行,源码、结构树节点和原始行位置必须保留。
|
||
/// 比较绘制几何及兼容 main 入口的命令等价性;两个独立 VM 的业务对象身份应不同。
|
||
fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() {
|
||
// 单次 compile 直接接收 PAGE 顶层源码,源快照必须原样保留,不能插入宿主包装函数。
|
||
let document = compile(PAGE, "app.main", 9).unwrap();
|
||
assert_eq!(document.source, PAGE);
|
||
// 结构树应保留四个顶层语句、名称及原始行号,ID 带当前提交来源。
|
||
let tree = document.root.as_ref().unwrap();
|
||
assert_eq!(tree.kind, "script");
|
||
assert_eq!(tree.children.len(), 4);
|
||
assert_eq!(tree.children[0].name, "heading");
|
||
assert_eq!(tree.children[1].name, "rectangle");
|
||
assert_eq!(tree.children[2].name, "myui");
|
||
assert_eq!(tree.children[2].start_line, 3);
|
||
assert_eq!(tree.children[3].start_line, 4);
|
||
assert!(tree.id.contains("app.main@9"));
|
||
// 布局结果应为两条业务命令,文字与矩形位置按真实测量/间距生成。
|
||
assert_eq!(document.frame.commands.len(), 2);
|
||
assert!(
|
||
matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor")
|
||
);
|
||
assert!(
|
||
matches!(&document.frame.commands[1], RenderCommand::RoundedRect { x: 24.0, y: 76.0, width: 240.0, height: 80.0, radius: 12.0, color } if color == "#DCE8FF")
|
||
);
|
||
assert_eq!(document.display_list(2.0).unwrap().commands().len(), 3);
|
||
// 另加载兼容 main 示例,序列化比较两种入口的绘制命令等价;不同 VM 对象身份仍应独立。
|
||
let wrapped = compile(include_str!("../../../examples/main.endor"), "app.main", 10).unwrap();
|
||
assert_eq!(
|
||
serde_json::to_value(&document.frame.commands).unwrap(),
|
||
serde_json::to_value(&wrapped.frame.commands).unwrap()
|
||
);
|
||
// 不同 VM 可产生相同绘图命令,但其托管对象身份与展示上下文必须独立。
|
||
assert_ne!(
|
||
document.frame.mounts[0].block_id,
|
||
wrapped.frame.mounts[0].block_id
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 提交含括号注释、辅助函数、多行赋值/调用及跨行成员链的完整源程序。
|
||
/// 检查渲染成功与结构树起止行,避免用宿主按行切割或简单括号统计代替 Aster 语法解析。
|
||
fn script_helpers_multiline_bindings_calls_and_member_continuations_use_aster() {
|
||
// 夹具保留括号注释、多行赋值/调用与成员续行,结构树断言依赖这些原始行号,不改字符串内部内容。
|
||
let source = r##"let color = "#242422" // comment with ( and )
|
||
function greeting(): Text {
|
||
return "Hello Endor"
|
||
}
|
||
function heading(value: Text): View {
|
||
return ui.text(value, 2, color)
|
||
}
|
||
let title =
|
||
heading(greeting())
|
||
let rectangle = ui.rect(
|
||
240, 80, 12,
|
||
"#DCE8FF"
|
||
)
|
||
let page = ui
|
||
.page()
|
||
page
|
||
.render(ui.column(24, 12, title, rectangle))
|
||
"##;
|
||
// 完整交给 Aster 解析、编译和执行,不能由宿主逐行切割或计数括号来模拟。
|
||
let document = compile(source, "app.main", 2).unwrap();
|
||
assert_eq!(document.frame.commands.len(), 2);
|
||
// 消费编译产生的结构树,核对辅助函数和多行语句的起止行覆盖完整原文范围。
|
||
let tree = document.root.unwrap();
|
||
assert_eq!(tree.children[1].name, "greeting");
|
||
assert_eq!(tree.children[1].start_line, 2);
|
||
assert_eq!(tree.children[2].name, "heading");
|
||
assert_eq!(tree.children[2].start_line, 5);
|
||
assert_eq!(tree.children[3].start_line, 8);
|
||
assert_eq!(tree.children[3].end_line, 9);
|
||
assert_eq!(tree.children.last().unwrap().end_line, 17);
|
||
}
|
||
|
||
#[test]
|
||
/// 分别使用 LF/CRLF/CR,在前面含中文的脚本中制造 missing 引用。
|
||
/// 诊断行列、字节 span 切片和提交来源都需对应原文;另检查语法错误的原始行号。
|
||
fn script_diagnostics_refer_to_original_unicode_source_and_byte_ranges() {
|
||
// 分别测试 LF/CRLF/CR,中文前缀使字符列与字节偏移不同,二者都需对应原始文本。
|
||
for ending in ["\n", "\r\n", "\r"] {
|
||
// 在第三行构造 missing 引用,换行形式变化不能让诊断误指向宿主生成位置。
|
||
let source = format!(
|
||
"let title = ui.text(\"中文\"){ending}// comment{ending}let broken = missing{ending}ui.page().render(title)"
|
||
);
|
||
// compile 应返回真实 frontend 诊断,而非包装后失去来源的普通字符串。
|
||
let error = compile(&source, "app.main", 27).unwrap_err();
|
||
// 读取行列、字节 span 与 submission 来源,直接切片原文以验证范围。
|
||
let diagnostic = error.downcast_ref::<frontend::SourceDiagnostic>().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::<frontend::SourceDiagnostic>().unwrap();
|
||
assert_eq!(diagnostic.location.line, 2);
|
||
}
|
||
|
||
#[test]
|
||
/// 两个 Page 交替 render 后,把 first.render 保存成函数再调用。
|
||
/// 绑定方法必须仍持有原接收者,最后一次 render 选择显示内容 last。
|
||
fn pages_bind_render_methods_and_last_render_selects_the_snapshot() {
|
||
// 先在两个 Page 分别 render,再把 first.render 保存成 paint 调用,检验绑定方法保留接收者。
|
||
let source = r#"let first = ui.page()
|
||
let second = ui.page()
|
||
first.render(ui.text("first"))
|
||
second.render(ui.text("second"))
|
||
let paint = first.render
|
||
paint(ui.text("last"))"#;
|
||
// 最终 compile 只发布最后一次被选择的页面快照,应只有 last 一条文字命令。
|
||
let document = compile(source, "app.main", 1).unwrap();
|
||
assert_eq!(document.frame.commands.len(), 1);
|
||
assert!(
|
||
matches!(&document.frame.commands[0], RenderCommand::Text { value, .. } if value == "last")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 先发布成功文档,再逐项提交无 render、错误 Page 参数、错误根和 render 后失败的源码。
|
||
/// 每个候选都必须失败,已发布 source 与 revision 保持原值,不能出现部分帧。
|
||
fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() {
|
||
// NamespaceState 保存最后成功文档与 revision,失败候选不能修改它们。
|
||
let mut state = NamespaceState {
|
||
submission: 1,
|
||
..NamespaceState::default()
|
||
};
|
||
// 先提交 PAGE 成功基线,后续每轮失败都与这个已发布状态比较。
|
||
state
|
||
.commit(1, compile(PAGE, "app.main", 1).unwrap())
|
||
.unwrap();
|
||
// 分别覆盖无 render、构造/调用参数错、根类型错以及 render 后继续执行失败。
|
||
for source in [
|
||
"ui.page()",
|
||
"let title = ui.text(\"no render\")",
|
||
"let page = ui.page(1)\npage.render(ui.text(\"x\"))",
|
||
"let page = ui.page()\npage.render()",
|
||
"let page = ui.page()\npage.render(\"not a node\")",
|
||
"let page = ui.page()\npage.render(ui.text(\"partial\"))\nui.missing()",
|
||
] {
|
||
// 为每个候选取新提交令牌,但不因提交请求本身提升已发布 revision。
|
||
state.submission = state.next_submission().unwrap();
|
||
// 要求每个源码候选都失败,随后核对文档 source 与 revision 保持成功基线。
|
||
assert!(
|
||
compile(source, "app.main", state.submission).is_err(),
|
||
"{source}"
|
||
);
|
||
assert_eq!(state.document.source, PAGE);
|
||
assert_eq!(state.revision, 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 保留超过 MAX_PAGES 的页面引用后再请求显示,检查明确的 64 page budget 错误。
|
||
/// 页面资源预算独立于最终画了多少节点,不能靠只显示一个页面绕过。
|
||
fn page_creation_has_a_separate_live_object_budget() {
|
||
// 动态生成并保留不同全局 Page 名字,使所有页面同时存活,不能被临时值回收绕过预算。
|
||
let mut source = String::new();
|
||
// 包含上界,创建 MAX_PAGES+1 个页面,明确越过页面活对象预算。
|
||
for index in 0..=ui_bridge::MAX_PAGES {
|
||
source.push_str(&format!("let page{index} = ui.page()\n"));
|
||
}
|
||
// 最终只显示 page0,证明页面分配预算独立于可见页面数及绘制节点数。
|
||
source.push_str("page0.render(ui.text(\"x\"))");
|
||
// 加载应明确报告 64 page budget,而不是等到渲染图展开预算才失败。
|
||
let error = compile(&source, "app.main", 1).unwrap_err();
|
||
assert!(error.to_string().contains("64 page budget"), "{error:#}");
|
||
}
|
||
|
||
#[test]
|
||
/// 生成每层重复调用前一函数的指数展开计算,最后才创建文字。
|
||
/// 即使尚未触及 UI 节点预算,指令总预算仍应终止计算并报告 instructions。
|
||
fn script_execution_is_limited_even_without_creating_ui_nodes() {
|
||
// 底层 text0 只返回字符串,后续逐层重复调用它,以大量纯计算消耗 VM 指令预算。
|
||
let mut source = "function text0(): Text { return \"Hello Endor\" }\n".to_owned();
|
||
// 每层调用前层两次形成指数工作量,但直到最后都不分配 UI 节点。
|
||
for index in 1..=16 {
|
||
source.push_str(&format!(
|
||
"function text{index}(): Text {{ let unused = text{}() return text{}() }}\n",
|
||
index - 1,
|
||
index - 1
|
||
));
|
||
}
|
||
// 最后才把 text16 结果交给 ui.text,指令限制应在构造画面前截断计算。
|
||
source.push_str("let title = ui.text(text16())\nui.page().render(title)");
|
||
// 检查 instructions 预算诊断,区分 UI 节点预算或递归帧深度限制。
|
||
let error = compile(&source, "app.main", 1).unwrap_err();
|
||
assert!(
|
||
error
|
||
.to_string()
|
||
.contains("execution limit reached: instructions"),
|
||
"{error:#}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 源 main 返回普通文字,再传给 ui.text 并挂载。
|
||
/// 验证 VM 的字符串结果可正确被宿主解码成发布命令,而非要求它已经是视觉对象。
|
||
fn literal_string_return_from_aster_can_be_displayed_by_the_host() {
|
||
// main 返回普通 Text,再由 ui.text 转为视觉对象,检验源值与宿主视觉对象转换边界。
|
||
let source = "function main(): Text { return \"Hello Endor\" }\nlet title = ui.text(main())\nui.page().render(title)";
|
||
// 编译执行后检查实际文字命令,不能仅以函数返回值正确推断宿主显示成功。
|
||
let document = compile(source, "app.main", 1).unwrap();
|
||
assert!(
|
||
matches!(&document.frame.commands[0], RenderCommand::Text { value, .. } if value == "Hello Endor")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 为相同源码生成两个不同提交的结构树,开发工具先选中旧子节点。
|
||
/// 接收新文档后旧 ID 不再有效,选择应回退到新树根,避免沿用旧提交位置。
|
||
fn developer_selection_resets_to_the_new_source_snapshot() {
|
||
// 同一源码的旧提交结构树,节点 ID 含提交身份。
|
||
let first = compile(PAGE, "app.main", 1).unwrap();
|
||
// 新提交生成不同节点 ID,即使源内容未变也不能沿用旧选择。
|
||
let second = compile(PAGE, "app.main", 2).unwrap();
|
||
// 模拟开发工具已打开且选中旧子节点,再通过文档更新入口恢复选择。
|
||
let mut tools = crate::developer_tools::DeveloperToolsState::default();
|
||
// 设置真实旧选择状态,避免默认空选择直接回退根而未覆盖旧 ID 失效。
|
||
tools.open = true;
|
||
tools.selected_node_id = Some(first.root.as_ref().unwrap().children[0].id.clone());
|
||
// 接收新文档后应选中新根,不能把旧节点位置误映射到新提交。
|
||
tools.document_updated(&second);
|
||
assert_eq!(
|
||
tools.selected_node_id.as_deref(),
|
||
Some(second.root.as_ref().unwrap().id.as_str())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 通过兼容模块/main 示例检查结构树、布局、文字实际测量和命令位置。
|
||
/// compile 路径清理候选 VM 后,两种显示倍率仍能从拥有的数据生成显示列表,证明发布不依赖悬空 VM 句柄。
|
||
fn executes_aster_functions_and_renders_after_vm_cleanup() {
|
||
// 兼容模块/main 示例经过单次 compile,返回时候选 VM 已清理,发布文档必须拥有绘制所需数据。
|
||
let document = compile(EXAMPLE, "app.main", 1).unwrap();
|
||
assert_eq!(document.source, EXAMPLE);
|
||
// 模块结构树应保留 namespace、函数名及原始行位置,独立于之后的 VM 生命周期。
|
||
let tree = document.root.as_ref().unwrap();
|
||
assert_eq!(tree.kind, "module");
|
||
assert_eq!(tree.name, "app.main");
|
||
assert_eq!(tree.children[0].name, "heading");
|
||
assert_eq!(tree.children[1].name, "main");
|
||
assert_eq!(tree.children[1].start_line, 5);
|
||
// 检查四条业务绘制命令及其绝对位置,后面的显示列表另含背景命令。
|
||
assert_eq!(document.frame.commands.len(), 4);
|
||
assert!(
|
||
matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor")
|
||
);
|
||
assert!(matches!(
|
||
&document.frame.commands[1],
|
||
RenderCommand::RoundedRect {
|
||
x: 24.0,
|
||
y: 76.0,
|
||
width: 240.0,
|
||
height: 80.0,
|
||
..
|
||
}
|
||
));
|
||
// 使用实际文字度量推导后续文字 x,避免写死字体相关宽度导致位置断言失真。
|
||
let first_width = crate::text::measure("Aster VM", crate::text::TextStyle::ui(1)).width;
|
||
assert!(matches!(
|
||
&document.frame.commands[3],
|
||
RenderCommand::Text { x, y: 168.0, .. } if (*x - (24.0 + first_width + 12.0 + 8.0 + 12.0)).abs() < 0.01
|
||
));
|
||
// compile 已清理候选 VM;以下重绘只能使用发布的原生数据,两种显示倍率都不能再依赖 VM 对象。
|
||
assert_eq!(document.display_list(1.0).unwrap().commands().len(), 5);
|
||
assert_eq!(document.display_list(2.0).unwrap().commands().len(), 5);
|
||
}
|
||
|
||
#[test]
|
||
/// 辅助函数接收借用 child 并把它放入 Row 两次。
|
||
/// 编译完成后两个文字命令各自有正确几何,覆盖变量/返回值/共享子项之间的托管引用有效性。
|
||
fn borrowed_ui_handles_survive_variables_helpers_and_reused_children() {
|
||
// main 局部 child 传给 helper,helper 在 Row 中复用两次,考察借用/返回/共享引用的所有权衔接。
|
||
let source = r#"function wrap(child: View): View { return ui.row(4, 8, child, child) }
|
||
function main(): View { let child = ui.text("shared") return wrap(child) }"#;
|
||
// 在任意命名空间编译,两个出现都需保留到文档发布,不能因局部变量退出被提前释放。
|
||
let document = compile(source, "arbitrary.namespace", 3).unwrap();
|
||
assert_eq!(document.frame.commands.len(), 2);
|
||
assert!(matches!(
|
||
&document.frame.commands[0],
|
||
RenderCommand::Text { x: 4.0, y: 4.0, .. }
|
||
));
|
||
// 按真实字体测量首文字宽,第二出现 x 应为起点 4+该宽+gap 8。
|
||
let width = crate::text::measure("shared", crate::text::TextStyle::ui(1)).width;
|
||
assert!(matches!(
|
||
&document.frame.commands[1],
|
||
RenderCommand::Text { x, y: 4.0, .. } if (*x - (4.0 + width + 8.0)).abs() < 0.01
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
/// 把同一模块示例换成 CRLF 或 CR,源快照必须逐字保留。
|
||
/// 结构树的 main 起始行仍为 5,检查行定位对不同换行形式一致。
|
||
fn architecture_ranges_follow_crlf_and_cr_source_snapshots() {
|
||
// 对同一模块分别使用 CRLF 与 CR,保证原样源快照和语句行号都支持不同换行形式。
|
||
for ending in ["\r\n", "\r"] {
|
||
// 只替换换行,不修改模块语义或插入包装,main 起始行仍应为 5。
|
||
let source = EXAMPLE.replace('\n', ending);
|
||
// 完整编译后既比较 source 逐字一致,也核对结构树定位。
|
||
let document = compile(&source, "app.main", 1).unwrap();
|
||
assert_eq!(document.source, source);
|
||
assert_eq!(document.root.unwrap().children[1].start_line, 5);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 表驱动拒绝旧 namespace/scene 语法、缺失或有参 main、错误根类型和各类非法 UI 参数。
|
||
/// 同时覆盖尺寸/字号/颜色预算与未知名称,确保兼容入口不会默默接受错误程序。
|
||
fn rejects_invalid_entry_objects_arguments_and_old_syntax() {
|
||
// 独立编译每个非法程序,覆盖旧语法、main 契约、宿主参数及未知 API/名称。
|
||
for source in [
|
||
"namespace app.main export scene Main { render { Text(\"old\") } }",
|
||
"function helper(): View { return ui.text(\"missing main\") }",
|
||
"function main(value: View): View { return value }",
|
||
"function main(): View { return \"wrong root\" }",
|
||
"function main(): View { return main }",
|
||
"function main(): View { return ui.text(1) }",
|
||
"function main(): View { return ui.text(\"x\", 1.0) }",
|
||
"function main(): View { return ui.text(\"x\", 9) }",
|
||
"function main(): View { return ui.rect(-1, 1) }",
|
||
"function main(): View { return ui.rect(1000001, 1) }",
|
||
"function main(): View { return ui.rect(1, 1, 0, \"#中中\") }",
|
||
"function main(): View { return ui.column(0, 0, ui.text) }",
|
||
"function main(): View { return unknown(1) }",
|
||
"function main(): View { return ui.missing() }",
|
||
] {
|
||
// 本组只要求拒绝,不据此声称某个特定诊断阶段;失败消息列出被意外接受的源码。
|
||
assert!(
|
||
compile(source, "app.main", 1).is_err(),
|
||
"accepted: {source}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// 在中文字符串之后制造未知引用,检查 SourceDiagnostic 仍指向第三行和 submission 17。
|
||
/// 避免宿主包装源代码后把错误定位成合成代码位置。
|
||
fn source_diagnostics_keep_unicode_locations_and_submission_origin() {
|
||
// 中文字符串后第三行引用 missing,真实 frontend 错误必须保留原源位置和提交 17 来源。
|
||
let error = compile(
|
||
"function main(): View {\n let title = ui.text(\"中文\")\n return missing\n}",
|
||
"app.main",
|
||
17,
|
||
)
|
||
.unwrap_err();
|
||
// 下转为结构化 SourceDiagnostic,而非从渲染后的文本猜测行号。
|
||
let error = error.downcast_ref::<frontend::SourceDiagnostic>().unwrap();
|
||
assert_eq!(error.location.line, 3);
|
||
assert!(error.origin.to_string().contains("submission 17"));
|
||
}
|
||
|
||
#[test]
|
||
/// 无终止条件的 main 自递归必须被 VM 帧预算截断,报告 frames 而非让宿主栈溢出。
|
||
fn recursive_program_stops_at_the_vm_frame_limit() {
|
||
// 无终止条件递归 main,必须由 VM 调用帧上限终止;不应递归穿透到宿主栈溢出。
|
||
let error = compile("function main(): View { return main() }", "app.main", 1).unwrap_err();
|
||
assert!(
|
||
error
|
||
.to_string()
|
||
.contains("execution limit reached: frames")
|
||
);
|
||
}
|
||
|
||
/// 生成二叉视觉树程序。shared=true 每层只构造一个 child 再复用两次,false 则分别递归构造。
|
||
/// 用于把业务对象数量增长和展示出现位置展开增长分开测试,最终由 main 返回最高层节点。
|
||
fn nested_functions(levels: usize, shared: bool) -> String {
|
||
// 下方预算测试调用本生成器;叶函数只创建一个文字,从 level1 开始逐层生成二叉组合。
|
||
let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned();
|
||
// 按层定义函数,所有引用都指向已定义的前一层,排除名称解析失败干扰预算测试。
|
||
for index in 1..=levels {
|
||
// 当前层使用的子层编号,与 shared 分支共同控制业务对象数和出现展开数。
|
||
let previous = index - 1;
|
||
// 先求一次 child 再放入容器两次,使业务对象线性增长、展示出现指数增长。
|
||
if shared {
|
||
source.push_str(&format!("function node{index}(): View {{ let child = node{previous}() return ui.column(0, 0, child, child) }}\n"));
|
||
} else {
|
||
// 两次调用前层函数分别分配子树,业务对象数与展示规模都指数增长。
|
||
source.push_str(&format!("function node{index}(): View {{ return ui.column(0, 0, node{previous}(), node{previous}()) }}\n"));
|
||
}
|
||
}
|
||
// main 只返回最高层,调用方通过 levels/shared 参数选择要触发的资源预算。
|
||
source.push_str(&format!(
|
||
"function main(): View {{ return node{levels}() }}"
|
||
));
|
||
source
|
||
}
|
||
|
||
#[test]
|
||
/// 不共享子项的树先触发 4096 构造节点预算;共享树对象较少但展开出现位置更多。
|
||
/// 后者需报告 expanded Block graph,证明两类预算分别约束对象分配和渲染规模。
|
||
fn construction_and_expanded_layout_have_separate_budgets() {
|
||
// 不共享的 12 层二叉树先超出 4096 构造节点预算,验证分配阶段限制。
|
||
let error = compile(&nested_functions(12, false), "app.main", 1).unwrap_err();
|
||
assert!(error.to_string().contains("4096 node budget"), "{error:#}");
|
||
// 共享的 14 层仅少量业务对象,仍应因出现位置展开过大被另一预算拒绝。
|
||
let error = compile(&nested_functions(14, true), "app.main", 2).unwrap_err();
|
||
assert!(
|
||
error.to_string().contains("expanded Block graph"),
|
||
"{error:#}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 构造 64 层嵌套容器包住一个文字,完整编译后仍应有一条文字命令。
|
||
/// 验证允许的深度不会因宿主遍历截断而悄悄丢掉叶节点。
|
||
fn managed_ui_depth_is_not_truncated_at_64_levels() {
|
||
// 生成单链而非二叉展开,隔离深度可用性与宽度/节点数量预算。
|
||
let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned();
|
||
// 64 层每层只包一个 Column,允许的深度必须把唯一叶文字保留到最终帧。
|
||
for index in 1..=64 {
|
||
source.push_str(&format!(
|
||
"function node{index}(): View {{ return ui.column(0, 0, node{}()) }}\n",
|
||
index - 1
|
||
));
|
||
}
|
||
// main 从第 64 层返回根,确保完整遍历所有层。
|
||
source.push_str("function main(): View { return node64() }");
|
||
// 正常编译后应恰有一条文字命令,不能静默截断深层子项。
|
||
let document = compile(&source, "app.main", 1).unwrap();
|
||
assert_eq!(document.frame.commands.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
/// 直接测试 NamespaceState 的提交令牌:失败编译保留旧文档,新 submission 使旧候选失效。
|
||
/// clear 后旧候选同样不能复活画面;随后新令牌的合法候选可继续递增 revision。
|
||
fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() {
|
||
// 直接使用命名空间状态机模拟异步候选,不依赖真实后台线程即可检查提交令牌的时序边界。
|
||
let mut state = NamespaceState::default();
|
||
// 先取新令牌并成功提交,建立 revision=1 的已发布基线。
|
||
state.submission = state.next_submission().unwrap();
|
||
assert_eq!(
|
||
state
|
||
.commit(state.submission, compile(EXAMPLE, "app.main", 1).unwrap())
|
||
.unwrap(),
|
||
Some(1)
|
||
);
|
||
// 第二次提交的编译失败,应保留上一成功文档和 revision。
|
||
state.submission = state.next_submission().unwrap();
|
||
assert!(compile("invalid", "app.main", state.submission).is_err());
|
||
assert_eq!(state.revision, 1);
|
||
assert_eq!(state.document.source, EXAMPLE);
|
||
// 保存一个即将过期的令牌,用它编译出合法候选,区分“源码合法”与“仍可提交”。
|
||
let stale = state.submission;
|
||
// 候选本身有效,但后续更新 submission 后应因令牌过期而被忽略。
|
||
let candidate = compile(EXAMPLE, "app.main", stale).unwrap();
|
||
// 模拟更新请求先到达,旧合法候选也不能再覆盖当前文档。
|
||
state.submission = state.next_submission().unwrap();
|
||
assert!(state.commit(stale, candidate.clone()).unwrap().is_none());
|
||
// clear 模拟删除画面;旧候选在删除后同样不能复活,revision 保持 clear 返回值。
|
||
let deleted = state.clear().unwrap();
|
||
assert!(state.commit(stale, candidate).unwrap().is_none());
|
||
assert_eq!(state.revision, deleted);
|
||
assert!(state.document.source.is_empty());
|
||
// 清理后仍可接受新的合法令牌,不能把一次 clear 当作永久关闭命名空间。
|
||
state.submission = state.next_submission().unwrap();
|
||
// 在新令牌下重新编译,成功 commit 应从 deleted revision 再递增一次。
|
||
let candidate = compile(EXAMPLE, "app.main", state.submission).unwrap();
|
||
assert_eq!(
|
||
state.commit(state.submission, candidate).unwrap(),
|
||
Some(deleted + 1)
|
||
);
|
||
}
|
||
|
||
// 退出 fixture 在两个窗口展示同一文字,以 log 统计每个出现位置的卸载,并保留一个待取消任务。
|
||
const EXIT_SOURCE: &str = r#"
|
||
class ExitText: TextBlock {
|
||
log: Object
|
||
function onUnmount(self: ExitText, mount: Mount): Unit {
|
||
self.log.increment()
|
||
}
|
||
}
|
||
log = ui.state(0)
|
||
text = ExitText("exit",1,log)
|
||
page = Page()
|
||
page.render(text)
|
||
second = Window()
|
||
other = Page()
|
||
other.render(text)
|
||
other.moveToWindow(second)
|
||
pending = ui.sleep(ui,"pending",30000)
|
||
"#;
|
||
|
||
/// 直接读取退出测试中的 log 托管状态,不通过 execute,因关闭后普通输入已经被禁止。
|
||
fn exit_log(session: &ReplSession) -> Value {
|
||
// 退出测试调用此助手,直接读取仍保留的业务全局,不经过已禁止输入的 execute。
|
||
let globals = session.candidate.globals.unwrap();
|
||
// 要求夹具中的 log 存在且为状态对象,避免关闭后缺失日志被当作零。
|
||
let Some(Value::Object(log)) = session
|
||
.candidate
|
||
.vm
|
||
.dict_get(globals, &Value::Text("log".into()))
|
||
.unwrap()
|
||
else {
|
||
panic!("log")
|
||
};
|
||
// 只读 value 用于比较 onUnmount 执行次数,不恢复或推进任何源代码。
|
||
session.candidate.vm.get_attr(log, "value").unwrap()
|
||
}
|
||
|
||
#[test]
|
||
/// 两个窗口共享文字并保有 pending Task,shutdown 返回窗口/Mount/任务清理统计。
|
||
/// 两个出现位置各调用一次 onUnmount,重复 shutdown 不再调用,关闭后的 execute 被拒绝。
|
||
fn shutdown_unmounts_all_windows_once_and_cancels_tasks() {
|
||
// 两个窗口共享 ExitText,每处独立 Mount;夹具另有待取消 sleep,覆盖退出的三类资源。
|
||
let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 701, 1).unwrap();
|
||
// 退出前卸载次数为零,避免把加载期行为算作 shutdown 的清理结果。
|
||
assert_eq!(exit_log(&session), Value::Number(0_i64.into()));
|
||
// 真实 shutdown 返回资源清理统计;窗口应恰好两处,Mount/任务至少覆盖夹具创建的资源。
|
||
let (windows, mounts, tasks) = session.shutdown().unwrap();
|
||
assert_eq!(windows, 2);
|
||
assert!(mounts >= 2);
|
||
assert!(tasks >= 1);
|
||
assert_eq!(exit_log(&session), Value::Number(2_i64.into()));
|
||
// 重复退出必须幂等,不能再执行两个 onUnmount;随后普通 execute 必须拒绝。
|
||
session.shutdown().unwrap();
|
||
assert_eq!(exit_log(&session), Value::Number(2_i64.into()));
|
||
assert!(session.execute("42", "app.main").is_err());
|
||
}
|
||
|
||
#[test]
|
||
/// 手动将会话标为 faulted 后 shutdown,资源仍需清理但 log 保持 0。
|
||
/// 验证停止域的退出清理不能借生命周期回调重新执行源代码。
|
||
fn shutdown_stopped_runtime_never_reenters_source_hooks() {
|
||
// 建立正常挂载状态,再人为标记 faulted,隔离“已停止运行时退出”这条路径。
|
||
let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 702, 1).unwrap();
|
||
// 停止域只允许资源清理,不得为了卸载通知重新进入源 onUnmount。
|
||
session.faulted = true;
|
||
// 退出仍应成功,但日志保持零,证明没有执行夹具的源卸载钩子。
|
||
session.shutdown().unwrap();
|
||
assert_eq!(exit_log(&session), Value::Number(0_i64.into()));
|
||
}
|
||
|
||
#[test]
|
||
/// 把退出 onUnmount 改成启动 sleep;关闭期间必须拒绝新任务并报告 exiting 诊断。
|
||
/// 错误使运行时 fault,避免退出清理反而产生无法结束的后台工作。
|
||
fn shutdown_hooks_cannot_create_new_background_work() {
|
||
// 把正常卸载计数替换为创建 sleep,构造退出回调试图增加后台任务的非法行为。
|
||
let source = EXIT_SOURCE.replace("self.log.increment()", "ui.sleep(ui,\"late\",30000)");
|
||
// 加载时没有触发卸载,程序应先正常挂载,错误预期发生在 shutdown 内。
|
||
let (mut session, _) = ReplSession::load(&source, "app.main", 703, 1).unwrap();
|
||
// 检查退出阶段明确禁止新任务并 fault,会话不能因清理回调反而遗留新后台工作。
|
||
let error = session.shutdown().unwrap_err();
|
||
assert!(format!("{error:#}").contains("runtime is exiting; new tasks are forbidden"));
|
||
assert!(session.is_faulted());
|
||
}
|
||
|
||
#[test]
|
||
/// 以有限 400x300 视口比较 window/vertical/horizontal:普通页受窗口限制,延伸页仅沿声明轴增长。
|
||
/// append 新子项后延伸尺寸连同 gap 实时更新,覆盖页面布局模式的旧兼容入口。
|
||
fn page_modes_grow_on_the_declared_axis_and_follow_live_children() {
|
||
// 同样两个 450×350 子项与 gap=10,比较 window 有限尺寸和旧延伸模式的单轴内容增长。
|
||
for (mode, expected) in [
|
||
("window", (400.0, 300.0)),
|
||
("vertical", (400.0, 710.0)),
|
||
("horizontal", (910.0, 300.0)),
|
||
] {
|
||
// 每轮独立构造页面,模式是唯一变化,避免前一轮 append 留下的结构影响后续预期。
|
||
let source = format!(
|
||
r#"page=Page()
|
||
page.mode="{mode}"
|
||
page.gap=10
|
||
page.render(RectBlock(450,350))
|
||
page.append(RectBlock(450,350))"#
|
||
);
|
||
// 统一有限视口 400×300,让 window 模式有明确上限,延伸模式只在指定轴超出。
|
||
let (mut session, document) = ReplSession::load_with_viewport(
|
||
&source,
|
||
"app.main",
|
||
1,
|
||
300,
|
||
Some(crate::layout::Size {
|
||
width: 400.0,
|
||
height: 300.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 首个 Mount 是页面根,核对实际发布的表面布局尺寸而非仅 page.mode 字段。
|
||
let root = &document.frame.mounts[0];
|
||
assert_eq!((root.width, root.height), expected, "{mode}");
|
||
// 仅纵向模式再追加一项,内容高应为 3×350+2×10=1070。
|
||
if mode == "vertical" {
|
||
// 沿当前会话追加并取新文档,检查实时子项变化已进入延伸高度。
|
||
let next = session
|
||
.execute("page.append(RectBlock(450,350))", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert_eq!(next.frame.mounts[0].height, 1070.0);
|
||
}
|
||
// 横向模式追加后宽应为 3×450+2×10=1370,不能错误沿纵轴增长。
|
||
if mode == "horizontal" {
|
||
// 同样用真实 append 触发刷新,核对新根宽度而非重新加载预设尺寸。
|
||
let next = session
|
||
.execute("page.append(RectBlock(450,350))", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert_eq!(next.frame.mounts[0].width, 1370.0);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// Canvas 中两次使用同一按钮,业务 ID 相同但 Mount ID 不同,均接受带符号世界坐标。
|
||
/// 修改位置更新两处;切到另一普通页再回来,原 Page 的 pan/zoom 与页面身份仍保留。
|
||
fn canvas_positions_are_live_signed_and_shared_without_copying_objects() {
|
||
// 旧 canvas Page 模式两次放入同一按钮,带负位置与非默认 pan/zoom,用来检验业务共享和页面状态保留。
|
||
let (mut session, initial) = ReplSession::load_with_viewport(
|
||
r#"
|
||
item=Button("same object")
|
||
item.x=-120
|
||
item.y=90
|
||
page=Page()
|
||
page.mode="canvas"
|
||
page.render(item)
|
||
page.append(item)
|
||
page.panX=-160
|
||
page.zoom=2
|
||
"#,
|
||
"app.main",
|
||
1,
|
||
301,
|
||
Some(crate::layout::Size {
|
||
width: 400.0,
|
||
height: 300.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 只取按钮出现,要求业务 block_id 相同、Mount ID 不同;不能通过复制业务对象实现两处显示。
|
||
let mounts: Vec<_> = initial
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.filter(|mount| mount.interactive)
|
||
.collect();
|
||
// 两处出现均保留负世界坐标,表面另记录 panX=-160 和 zoom=2。
|
||
assert_eq!(mounts.len(), 2);
|
||
assert_eq!(mounts[0].block_id, mounts[1].block_id);
|
||
assert_ne!(mounts[0].id, mounts[1].id);
|
||
assert_eq!((mounts[0].x, mounts[0].y), (-120.0, 90.0));
|
||
assert_eq!(initial.frame.surface.pan.x, -160.0);
|
||
assert_eq!(initial.frame.surface.zoom, 2.0);
|
||
// 写同一业务 item 的 x/y,两处出现都应更新为 (500,-60),不能只移动首个 Mount。
|
||
let next = session
|
||
.execute("item.x=500\nitem.y=-60", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert!(
|
||
next.frame
|
||
.mounts
|
||
.iter()
|
||
.filter(|m| m.interactive)
|
||
.all(|m| m.x == 500.0 && m.y == -60.0)
|
||
);
|
||
// 保存原 canvas 页身份,后面切换到普通页面再返回,必须保留同一页状态。
|
||
let old_page = next.frame.surface.page.clone();
|
||
// 新建普通 Page 并 render,当前表面切为 Window 模式,但不销毁原 canvas 页面业务状态。
|
||
let window = session
|
||
.execute(
|
||
"other=Page()\nother.render(TextBlock(\"other\"))",
|
||
"app.main",
|
||
)
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert_eq!(window.frame.surface.mode, crate::page::Mode::Window);
|
||
// 激活原页后核对 page 身份、pan 和 zoom 全部恢复,不以新建页面代替旧页。
|
||
let restored = session
|
||
.execute("page.activate()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
assert_eq!(restored.frame.surface.page, old_page);
|
||
assert_eq!(restored.frame.surface.pan.x, -160.0);
|
||
assert_eq!(restored.frame.surface.zoom, 2.0);
|
||
}
|
||
|
||
#[test]
|
||
/// 通过 ui.newTab 加载内置起始页并确认二维视口存在。
|
||
/// 逐个按画面文字位置点击示例入口,检查它们都使用 Window 表面、仅延伸示例带 ScrollBlock,再用真实 tab-activate 回到首页。
|
||
fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() {
|
||
// 以 1024×800 窗口初始化会话,测试实际内置起始页在有限约束下的交互入口。
|
||
let mut session = ReplSession::new_with_viewport(
|
||
302,
|
||
Some(crate::layout::Size {
|
||
width: 1024.0,
|
||
height: 800.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// 通过公开 ui.newTab 创建起始页,后续每次派发都沿最新文档继续,避免版本过期。
|
||
let mut document = session
|
||
.execute("ui.newTab()", "app.main")
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 起始页采用 Window 表面且实际包含 CanvasViewport,二维场景应由独立视口承载。
|
||
assert_eq!(document.frame.surface.mode, crate::page::Mode::Window);
|
||
assert!(
|
||
document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.any(|mount| mount.canvas_viewport)
|
||
);
|
||
// 依次点击普通窗口、纵向延伸和横向延伸入口;只有后两者应出现独立 ScrollBlock。
|
||
for (label, scrolling) in [("窗口页面", false), ("纵向延伸", true), ("横向延伸", true)]
|
||
{
|
||
// 先保存首页标签 ID,进入示例后用真实 tab-activate 返回它,而不是重新创建首页。
|
||
let home_tab = document
|
||
.frame
|
||
.tabs
|
||
.iter()
|
||
.find(|tab| tab.title == "自由画布")
|
||
.unwrap()
|
||
.id;
|
||
// 按当前绘制文字找到入口屏幕位置,检验可见标签确实对应可点击目标。
|
||
let point = document
|
||
.frame
|
||
.commands
|
||
.iter()
|
||
.find_map(|command| match command {
|
||
// 匹配目标标签后在字内取一点,不用硬编码按钮 Mount 或跳过真实命中检查。
|
||
RenderCommand::Text { x, y, value, .. } if value == label => {
|
||
Some(crate::geometry::Point::new(x + 2.0, y + 2.0))
|
||
}
|
||
// 其他文字/绘制命令都不参与此次入口定位。
|
||
_ => None,
|
||
})
|
||
.unwrap();
|
||
// 整帧 hit_test 必须从标签坐标找到有效按钮,保证显示和交互几何一致。
|
||
let mount = document
|
||
.frame
|
||
.hit_test(point)
|
||
.expect("button label must be clickable")
|
||
.id;
|
||
// 使用当前 session/layout_version 与屏幕坐标派发原生 click,并保存新发布文档。
|
||
document = session
|
||
.dispatch(&crate::events::Input {
|
||
window: 1,
|
||
session: document.frame.session,
|
||
layout_version: document.frame.layout_version,
|
||
mount,
|
||
kind: "click".into(),
|
||
point: Some([point.x, point.y]),
|
||
press_point: Some([point.x, point.y]),
|
||
delta: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap()
|
||
.document
|
||
.unwrap();
|
||
// 三个示例均使用 Window 表面;延伸由内部滚动容器实现,不能退回旧表面模式。
|
||
assert_eq!(
|
||
document.frame.surface.mode,
|
||
crate::page::Mode::Window,
|
||
"{label}"
|
||
);
|
||
// 检查 scroll 上下文是否与当前入口预期一致,并要求整个帧没有 fault。
|
||
assert_eq!(
|
||
document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.any(|mount| mount.scroll.is_some()),
|
||
scrolling,
|
||
"{label}"
|
||
);
|
||
assert!(document.frame.fault.is_none());
|
||
// 以示例文档当前版本激活保存的首页标签,不复用进入示例前的旧布局版本。
|
||
let input = crate::events::Input {
|
||
window: 1,
|
||
session: document.frame.session,
|
||
layout_version: document.frame.layout_version,
|
||
mount: home_tab,
|
||
kind: "tab-activate".into(),
|
||
point: None,
|
||
press_point: None,
|
||
delta: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
};
|
||
// 返回首页后的新文档作为下一轮入口查找基准,覆盖连续来回切页的版本更新。
|
||
document = session.dispatch(&input).unwrap().document.unwrap();
|
||
}
|
||
}
|