Files
endorphin-tessera-gui/src/runtime/endor_host/tests.rs
T

8730 lines
370 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 运行时集成回归与示例验收。测试经真实 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]);
// 同字串加宽后的实际绘制行高和原来比较,排除仅命令宽字段被改、字形仍按旧行排的情况。
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""#
);
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。
/// 短暂休眠让后台文件线程获得执行机会;取消/失败也算终态,超时则明确使测试失败。
fn complete_file_task(session: &mut ReplSession) {
// 测试调用方依赖此有界等待推进后台文件结果;达到上限必须失败,不能无限挂住测试进程。
for _ in 0..1000 {
// 后台只填 mailbox,必须显式让 owning VM poll 才能执行回调或完成无回调 Task。
session.poll_tasks().unwrap();
// 只要状态离开 pending 就交还调用方,让其按当前测试目的区分成功、取消或失败。
if file_task_state(session) != "pending" {
// 不只 Completed,Cancelled/Failed 也结束等待,具体预期由各调用测试单独断言。
return;
}
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。
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()
);
// 只改 title 不改 Page 身份,标签文字更新但第一页原稳定 Tab 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 合并为最后尺寸,并保留 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);
// 调用上面的事件助手触发颜色按钮;事件路径与正常 UI 输入相同。
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());
}
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");
};
// 每片只保留当前出现位置的辅助对象/结果;不能在首次让出前为所有位置预建 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();
// 要求布局也实际暂停,随后用与绘制相同的固定阈值核对辅助对象增长。
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"
);
// 取消应恰好发生一次,多片绘制统计则证明此用例确实跨越过执行切片。
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");
// 1100 次出现使 Mount 通知超过单批容量;每次追加的是同一业务对象引用。
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 入口的 native signature,防止仅部分函数注册了契约元数据。
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:#}"
);
}
// 先核对示例保存的原始颜色结果仍正确,再执行合法 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 成功派发;若守卫泄漏,会误报重复派发。
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"
);
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 {
// 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。
/// 子项宽度始终为自然宽度,mainAlign 只分配剩余间隙,不应被误当成 flex 拉伸。
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,center 应从起点溢出,不能用负剩余空间计算出负偏移。
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 仅覆盖父内容区域;命中在原按钮矩形内也应因裁剪而失败。
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,区分显式共享和初始默认独立。
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();
// 依次核对固定蓝层、可见红块、视口外背景以及矩形右侧背景,捕获缩放或裁剪遗漏。
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 名义类型,保证兼容原有滚动 API。
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,要求产生有效新文档。
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"
);
// 从源 Mount.toLocal 反投影屏幕中心,数值解析后应近似恢复纹理坐标 50,与 toView 互相校验。
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 应因遮挡失败;整帧命中回落到外层三维视口,不能穿透到按钮。
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,需要联动内、外两个祖先而非只滚最近一层。
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,
// 对外沿用原签名,使本用例只改变实现,不借参数契约变化制造缓存失效。
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]
/// 从同一 desktop 的第二窗口查到 Block.measure,检查原生快速路径认的是方法与注册表组合。
/// 同函数换成 paint 名称或外来 types 表都不能被认作 intrinsic,避免只凭对象形状或方法名跳过源执行。
fn native_visual_identity_accepts_shared_window_registries_only() {
// 建立同一 desktop 下的会话和第二窗口,验证窗口共享类型注册表可以使用相同原生快速路径。
let mut session = ReplSession::new(238).unwrap();
// 真实 Window 构造注册第二窗口,不手工拼造一个看似相同的 desktop。
session.execute("second = Window()", "app.main").unwrap();
// 从全局取得第二窗口,沿其 ManagedWindow payload 找到实际所属 desktop。
let globals = session.candidate.globals.unwrap();
// 必须为真实 Window 对象,保证后面的注册表比较来自正常窗口生命周期。
let Value::Object(window) = session
.candidate
.vm
.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。
/// 通过 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();
}
}