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

7656 lines
258 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 程序,再看后续变更与最终断言;编译成功、回调成功、文档发布成功是不同检查点。
//!
use super::*;
use crate::document::{NamespaceState, RenderCommand};
const EXAMPLE: &str = include_str!("../../../examples/hello.endor");
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 {
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")),
] {
for dpi in [1., 2.] {
let (mut session, mut document) = ReplSession::load_with_viewport(
source,
"app.main",
1,
988,
Some(crate::layout::Size {
width: 1024.,
height: 800.,
}),
)
.unwrap();
let (width, height) = ((1024. * dpi) as u32, (800. * dpi) as u32);
let mut pixels = vec![0; (width * height) as usize];
let mut updates = Vec::new();
let mut draws = Vec::new();
let mut cached = Vec::new();
// 连续做 33 轮,前三轮预热;每轮使用最新发布版本构造事件,避免基准测到过期输入拒绝。
for sample in 0..33 {
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();
let update = start.elapsed().as_secs_f64() * 1000.;
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.;
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() {
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();
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() {
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();
let ids = initial
.frame
.mounts
.iter()
.map(|m| m.id)
.collect::<Vec<_>>();
let width0 = session
.execute("paragraph.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value;
let width1 = session
.execute("paragraph.mounts().at(1).bounds().width", "app.main")
.unwrap()
.value;
assert_eq!(width0, "280");
assert_eq!(width1, "360");
let height = session
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
.unwrap()
.value
.parse::<f32>()
.unwrap();
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);
let changed = session
.execute("wide.click()", "app.main")
.unwrap()
.document
.unwrap();
assert_eq!(
changed
.frame
.mounts
.iter()
.map(|m| m.id)
.collect::<Vec<_>>(),
ids
);
let wider = session
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
.unwrap()
.value
.parse::<f32>()
.unwrap();
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() {
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() {
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"))
"##;
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] {
let constructor = if custom { "Wrapped" } else { "TextBlock" };
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)))
"#
);
let (mut session, initial) = ReplSession::load_with_viewport(
&source,
"app.main",
1,
230,
Some(crate::layout::Size {
width: 240.0,
height: 2000.0,
}),
)
.unwrap();
let ids: Vec<_> = initial.frame.mounts.iter().map(|m| m.id).collect();
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]);
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);
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<_>>()
);
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]);
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() {
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()
);
}
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());
let globals = session.candidate.globals.unwrap();
let Value::Object(received) = session
.candidate
.vm
.dict_get(globals, &Value::Text("received".into()))
.unwrap()
.unwrap()
else {
panic!("state")
};
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)",
] {
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();
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
);
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() {
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();
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();
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() {
let source = r#"head=TextBlock("head")
tail=TextBlock("tail")
list=Column(24,8,head,tail)
page=Page()
page.render(list)"#;
let (mut session, initial) = ReplSession::load(source, "app.main", 1, 200).unwrap();
session
.execute("held=tail.mounts().at(0)", "app.main")
.unwrap();
let id = session
.execute("held.id", "app.main")
.unwrap()
.value
.parse::<u64>()
.unwrap();
let old = initial
.frame
.mounts
.iter()
.find(|mount| mount.id == id)
.unwrap();
let inserted = session
.execute("slot=list.insert(1,TextBlock(\"middle\"))", "app.main")
.unwrap();
assert_eq!(inserted.value, "2");
let document = inserted.document.unwrap();
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()
);
for invalid in [
"list.insert(4,head)",
"list.insert(0,list)",
"list.insert(-1,head)",
] {
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();
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() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/tab-placement.endor"),
"app.main",
1,
201,
)
.unwrap();
session.take_window_commands().unwrap();
let page = initial.frame.tabs[0].id;
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();
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();
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() {
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)"#;
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"
);
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();
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);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
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) {
let _ = std::fs::remove_file(&self.0);
}
}
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 {
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 {
let globals = session.candidate.globals.unwrap();
let Value::Object(loader) = session
.candidate
.vm
.dict_get(globals, &Value::Text("loader".into()))
.unwrap()
.unwrap()
else {
panic!("loader");
};
let Value::Object(task) = session.candidate.vm.get_attr(loader, "task").unwrap() else {
panic!("Task");
};
let definition = session
.candidate
.vm
.object_store()
.type_of(task)
.unwrap()
.unwrap();
let method = session
.candidate
.vm
.object_store()
.lookup_method(definition, "status")
.unwrap()
.unwrap();
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 {
session.poll_tasks().unwrap();
if file_task_state(session) != "pending" {
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| {
with_file_task_input("new UTF-8 文本", |new| {
let path = serde_json::to_string(&new.to_string_lossy()).unwrap();
let tail = format!(
"old = loader.task\nloader.task = ui.readText(loader,\"read\",{path},loader.apply)"
);
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| {
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\""
);
let missing = path.with_extension("missing");
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| {
let path = serde_json::to_string(&path.to_string_lossy()).unwrap();
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})"
);
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() {
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)"#;
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();
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() {
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)"#;
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();
for name in ["pageTask", "mountTask"] {
assert_eq!(
session
.execute(&format!("{name}.status()"), "app.main")
.unwrap()
.value,
"\"cancelled\""
);
}
session.poll_tasks().unwrap();
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();
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() {
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()"#;
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());
let globals = session.candidate.globals.unwrap();
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() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/function-types.endor"),
"app.main",
1,
180,
)
.unwrap();
for expression in [
"isInstance(identity,Function)",
"isInstance(ui.rect,Function)",
"isInstance(actions.read,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();
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();
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() {
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();
let globals = session.candidate.globals.unwrap();
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"),
};
let held = lookup(&session, "held");
let window = lookup(&session, "other");
let marker = lookup(&session, "marker");
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());
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() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/windows.endor"),
"app.main",
1,
130,
)
.unwrap();
assert_eq!(initial.windows.len(), 1);
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);
let changed = session
.execute("count.increment()", "app.main")
.unwrap()
.document
.unwrap();
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();
let page = session.execute("right", "app.main").unwrap().value;
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();
let Value::Object(window) = session
.candidate
.vm
.dict_get(globals, &Value::Text("other".into()))
.unwrap()
.unwrap()
else {
panic!("Window");
};
let weak = session.candidate.vm.downgrade(window).unwrap();
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();
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() {
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();
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() {
let source = r#"first = Page()
first.title = "First"
first.render(TextBlock("one"))
second = Page()
second.title = "Second"
second.render(TextBlock("two"))"#;
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);
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,
};
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()
);
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);
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"
);
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);
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() {
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();
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());
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() {
let source = r#"first = Page()
second = Page()
first.render(TextBlock("first"))
second.render(TextBlock("second"))
window = ui.window()"#;
let (mut session, _) = ReplSession::load(source, "app.main", 112, 1).unwrap();
assert_eq!(
session
.execute("window.pages().count", "app.main")
.unwrap()
.value,
"2"
);
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();
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() {
let (mut session, _) = ReplSession::load(
"page = Page()\npage.render(TextBlock(\"page\"))",
"app.main",
113,
1,
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(page) = session
.candidate
.vm
.dict_get(globals, &Value::Text("page".into()))
.unwrap()
.unwrap()
else {
panic!("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() {
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"))"##;
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 {
let Value::Object(function) = session
.candidate
.vm
.get_attr(session.bridge.ui, "stats")
.unwrap()
else {
panic!("stats function")
};
let Value::Object(stats) = session.candidate.vm.call(function, &[]).unwrap() else {
panic!("stats")
};
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 {
let globals = session.candidate.globals.unwrap();
let Value::Object(block) = session
.candidate
.vm
.dict_get(globals, &Value::Text(name.into()))
.unwrap()
.unwrap()
else {
panic!("Block")
};
let mount = document
.frame
.mounts
.iter()
.find(|mount| mount.block_id == block.to_string())
.unwrap();
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() {
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();
assert!(
session
.execute("42", "app.main")
.unwrap()
.document
.is_none()
);
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() {
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);
let painted = click_named(&mut session, &initial, "blue");
assert!(painted.frame.commands.iter().any(
|command| matches!(command,RenderCommand::RoundedRect {color,..} if color=="#526AC7")
));
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);
let resized = click_named(&mut session, &painted, "expand");
assert!(
resized
.frame
.mounts
.iter()
.any(|mount| mount.width == 260.0 && mount.height == 60.0)
);
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() {
let mut session = ReplSession::new_with_viewport(
104,
Some(crate::layout::Size {
width: 800.0,
height: 600.0,
}),
)
.unwrap();
assert!(
session
.execute("42", "app.main")
.unwrap()
.document
.is_none()
);
assert!(
session
.resize(Some(crate::layout::Size {
width: 360.0,
height: 240.0
}))
.unwrap()
.is_none()
);
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() {
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)"##;
let (mut session, initial) = ReplSession::load(source, "app.main", 103, 1).unwrap();
let id = initial
.frame
.mounts
.iter()
.find(|mount| mount.width == 80.0)
.unwrap()
.id;
let globals = session.candidate.globals.unwrap();
let Value::Object(result) = session
.candidate
.vm
.dict_get(globals, &Value::Text("result".into()))
.unwrap()
.unwrap()
else {
panic!("result")
};
session
.candidate
.vm
.set_attr(result, "width", Value::Number(140.into()))
.unwrap();
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() {
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)
);
let ids: Vec<_> = initial.frame.mounts.iter().map(|mount| mount.id).collect();
let document = session
.resize(Some(crate::layout::Size {
width: 360.0,
height: 240.0,
}))
.unwrap()
.unwrap();
assert_eq!(
ids,
document
.frame
.mounts
.iter()
.map(|mount| mount.id)
.collect::<Vec<_>>()
);
assert!(
document
.frame
.mounts
.iter()
.any(|mount| mount.width == 312.0 && mount.height == 24.0)
);
assert_eq!(session.inputs, 0);
assert!(
session
.resize(Some(crate::layout::Size {
width: 360.0,
height: 240.0
}))
.unwrap()
.is_none()
);
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() {
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;
assert!(initial.frame.commands.iter().any(|command| matches!(command,RenderCommand::Text { value,y,.. } if value.starts_with("尺寸来自") && *y == 100.0)));
let globals = session.candidate.globals.unwrap();
let Value::Object(metrics) = session
.candidate
.vm
.dict_get(globals, &Value::Text("metrics".into()))
.unwrap()
.unwrap()
else {
panic!("Metrics")
};
session
.candidate
.vm
.set_attr(metrics, "width", Value::Number(260.into()))
.unwrap();
let next = session.refresh_document().unwrap().unwrap();
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() {
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))"#,
);
let (mut session, initial) = ReplSession::load(&source, "app.main", 92, 1).unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(rect) = session
.candidate
.vm
.dict_get(globals, &Value::Text("rect".into()))
.unwrap()
.unwrap()
else {
panic!("Rect")
};
let widths: Vec<_> = initial
.frame
.mounts
.iter()
.filter(|mount| mount.block_id == rect.to_string())
.map(|mount| mount.width)
.collect();
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() {
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)"##;
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();
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() {
for (body, message) in [
("return 1", "hitTest return expects Bool"),
(
"self.hits.increment()\nreturn point.inCircle(40, 40, 40)",
"read-only",
),
] {
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());
let globals = session.candidate.globals.unwrap();
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() {
let source = r##"hits = ui.state(0)
rect = RectBlock(80, 40, 0, "#DDEEFF")
token = rect.on("click", hits.increment)
page = Page()
page.render(rect)"##;
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 {
let children = std::iter::repeat_n("item", copies)
.collect::<Vec<_>>()
.join(", ");
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();
session
.bridge
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
.unwrap();
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] {
let (mut session, initial) =
ReplSession::load(&resumable_paint_source(copies, 0), "app.main", 280, 1).unwrap();
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);
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();
let root = session
.bridge
.prepare_frame(&mut session.candidate.vm)
.unwrap()
.unwrap();
let before = session.candidate.vm.object_count();
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() {
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();
if session
.refresh
.as_ref()
.is_some_and(|work| work.paint.is_some())
{
break;
}
}
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")));
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());
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")
));
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() {
let (mut session, initial) =
ReplSession::load(&resumable_paint_source(1, 300), "app.main", 279, 1).unwrap();
let viewports = std::collections::BTreeMap::new();
session
.execute_incremental("item.tint = \"invalid-color\"", "app.main", &viewports)
.unwrap();
let mut paused = false;
let mut failed = false;
for _ in 0..100 {
match session.resize_windows_incremental(&viewports, false) {
Ok(_) => {
paused |= session
.refresh
.as_ref()
.is_some_and(|work| work.paint.is_some())
}
Err(_) => {
failed = true;
break;
}
}
}
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() {
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)"##;
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() {
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")
)
);
let globals = session.candidate.globals.unwrap();
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() {
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)"##;
let mut session = ReplSession::new(72).unwrap();
assert!(
session
.execute(source, "app.main")
.err()
.unwrap()
.to_string()
.contains("read-only")
);
assert!(session.is_faulted());
let globals = session.candidate.globals.unwrap();
let Value::Object(rect) = session
.candidate
.vm
.dict_get(globals, &Value::Text("rect".into()))
.unwrap()
.unwrap()
else {
panic!("Block")
};
assert_eq!(
session.candidate.vm.get_attr(rect, "tint").unwrap(),
Value::Text("#FF0000".into())
);
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() {
let fixture = include_str!("../../../examples/source-paint.endor");
let mut source = fixture[..fixture.find("root = Column").unwrap()].to_owned();
source.push_str("root = Column(0, 0");
for _ in 0..1100 {
source.push_str(", rect");
}
source.push_str(")\npage = Page()\npage.render(root)");
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}"
);
}
let blocks = session
.execute("ui.stats().blocks", "app.main")
.unwrap()
.value;
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"),
] {
let error = session
.execute(expression, "app.main")
.err()
.expect("invalid typed constructor/API");
assert!(
error.to_string().contains(expected),
"{expression}: {error:#}"
);
}
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\""
);
for name in [
"open",
"present",
"text",
"rect",
"spacer",
"row",
"column",
"button",
"page",
"state",
"textOf",
"stats",
"viewport",
"window",
"isInstance",
"isSubtype",
"implement",
"implements",
"supports",
"after",
] {
let Value::Object(function) = session
.candidate
.vm
.get_attr(session.bridge.ui, name)
.unwrap()
else {
panic!("native Function")
};
let signature = session
.candidate
.vm
.object_store()
.native_signature(function)
.unwrap()
.expect("published UI signature");
signature
.validate(session.candidate.vm.object_store())
.unwrap();
}
}
#[test]
/// 加载满足参数逆变/返回协变的视觉方法覆盖,验证测量和点击都按动态类型执行。
/// 再分别缩窄参数、放宽返回类型,检查类定义被对应 override 诊断拒绝。
fn source_overrides_accept_wider_inputs_and_more_specific_results() {
let source = include_str!("../../../examples/method-contracts.endor");
let (mut session, document) = ReplSession::load(source, "app.main", 164, 1).unwrap();
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();
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")),
] {
let result = session.execute(expression, "app.main").unwrap();
if let Some(expected) = expected {
assert_eq!(result.value, expected, "{expression}");
}
}
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();
session
.execute("context = rect.mounts().at(0)", "app.main")
.unwrap();
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:#}"
);
}
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() {
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
);
}
assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { width: 200.0, height: 60.0, color, .. } if color == "#DDEEFF")));
session
.execute("context = rect.mounts().at(0)", "app.main")
.unwrap();
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"
);
assert!(
session
.execute(
"rect.measure(context, Constraints(200, 0, 100, 100))",
"app.main"
)
.is_err()
);
assert!(
session
.execute("root.paint(context, Bounds(0, 0, 10, 10))", "app.main")
.is_err()
);
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() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/visual-protocols.endor"),
"app.main",
62,
1,
)
.unwrap();
session
.execute("context = rect.mounts().at(0)", "app.main")
.unwrap();
let original = session.execute("context", "app.main").unwrap().value;
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"
);
assert!(
session
.execute("rect.invalidate(\"layout\")", "app.main")
.unwrap()
.document
.is_some()
);
assert_eq!(
session
.execute("ui.stats().measured", "app.main")
.unwrap()
.value,
"1"
);
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() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/mount-lifecycle.endor"),
"app.main",
51,
1,
)
.unwrap();
initial.validate().unwrap();
for (expression, expected) in [
("log.mounted.value", "2"),
("log.parentMounted", "1"),
("log.childMounted", "3"),
] {
assert_eq!(
session.execute(expression, "app.main").unwrap().value,
expected
);
}
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"
);
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"
);
assert_eq!(
session
.execute("log.childUnmounted", "app.main")
.unwrap()
.value,
"5"
);
assert_eq!(
session
.execute("log.parentUnmounted", "app.main")
.unwrap()
.value,
"6"
);
assert_eq!(
session.execute("saved.mounted", "app.main").unwrap().value,
"false"
);
assert!(session.execute("saved.bounds()", "app.main").is_err());
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() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/mount-lifecycle.endor"),
"app.main",
52,
1,
)
.unwrap();
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"
);
assert_eq!(
session
.execute("child.mounts().at(0).page()", "app.main")
.unwrap()
.value,
session.execute("second", "app.main").unwrap().value
);
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() {
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();
assert!(document.frame.commands.iter().any(
|command| matches!(command, RenderCommand::Text { value, .. } if value == "from hook")
));
let mut session = ReplSession::new(54).unwrap();
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 {
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")
};
let mount = document
.frame
.mounts
.iter()
.find(|mount| mount.interactive && mount.block_id == button.to_string())
.unwrap();
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_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
);
}
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() {
let (mut session, initial) = event_session(
"function cancel(event: Event): Unit { target.disconnect() }\nconnection = root.on(\"click\", cancel, \"capture\")",
);
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"
);
let (mut session, initial) = event_session(
"function stop(event: Event): Unit { event.stopPropagation() }\nconnection = button.on(\"click\", stop, \"capture\")",
);
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\")",
);
click_document(&mut session, &initial);
assert_eq!(
session
.execute("model.count.value", "app.main")
.unwrap()
.value,
"1"
);
let (mut session, _) = event_session(
"function prevent(event: Event): Unit { event.preventDefault() }\nconnection = button.on(\"custom\", prevent)\nresult = button.dispatch(Event(\"custom\"))",
);
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() {
let (mut session, _) = event_session("");
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()
);
session
.execute(
"failing.disconnect()\nbutton.dispatch(failedEvent)",
"app.main",
)
.unwrap();
session
.execute(
"orphan = Button(\"unmounted\")\ntoken = orphan.on(\"click\", model.target)",
"app.main",
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(orphan) = session
.candidate
.vm
.dict_get(globals, &Value::Text("orphan".into()))
.unwrap()
.unwrap()
else {
panic!("Block expected")
};
let weak = session.candidate.vm.downgrade(orphan).unwrap();
session
.candidate
.vm
.dict_delete(globals, &Value::Text("orphan".into()))
.unwrap();
session
.bridge
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
.unwrap();
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();
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"
);
session.execute("alias = label", "app.main").unwrap();
let globals = session.candidate.globals.unwrap();
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();
let updated = session
.execute(
r##"label = CustomStatus("replacement", 2, "#242422", "#237A45", "new field")"##,
"app.main",
)
.unwrap()
.document
.unwrap();
assert!(updated.frame.commands.iter().any(
|command| matches!(command, RenderCommand::Text { value, .. } if value == "new field")
));
session
.bridge
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
.unwrap();
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);
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() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/managed-types.endor"),
"app.main",
1,
1,
)
.unwrap();
initial.validate().unwrap();
assert_eq!(
session
.execute("isInstance(page, Block)", "app.main")
.unwrap()
.value,
"true"
);
let mount = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive)
.unwrap();
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();
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() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/scroll.endor"),
"app.main",
1,
240,
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(middle) = session
.candidate
.vm
.dict_get(globals, &Value::Text("middle".into()))
.unwrap()
.unwrap()
else {
panic!("expected Block");
};
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,
];
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();
assert_eq!(
session.execute("count.value", "app.main").unwrap().value,
"0"
);
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"
);
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();
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;
let target = frame
.mounts
.iter()
.find(|m| m.block_id == last.to_string())
.unwrap();
assert!(target.contains(crate::geometry::Point::new(
target.x + target.width / 2.0,
target.y + target.height / 2.0
)));
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() {
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}))"
);
let (mut session, _) = ReplSession::load(&source, "app.main", 272, 1).unwrap();
session.execute("saved = item.mounts().at(0)\nreceived = ui.state(0)\ntoken = ui.after(saved, \"keep\", 60000, received.increment)", "app.main").unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(saved) = session
.candidate
.vm
.dict_get(globals, &Value::Text("saved".into()))
.unwrap()
.unwrap()
else {
panic!("saved Mount");
};
let first = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 800.0,
height: 600.0,
},
)]);
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());
let latest = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 900.0,
height: 650.0,
},
)]);
assert!(
session
.resize_windows_incremental(&latest, false)
.unwrap()
.is_none()
);
assert!(!session.candidate.vm.has_suspended_call());
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);
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() {
let children = std::iter::repeat_n("item", 80)
.collect::<Vec<_>>()
.join(", ");
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();
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());
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() {
let children = std::iter::repeat_n("item", 80)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
"count = ui.state(0)\nitem = RectBlock(2,20)\npage = Page()\npage.render(Row(0,0,{children}))"
);
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();
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();
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,
},
)]);
session.resize_windows_incremental(&latest, false).unwrap();
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() {
let children = std::iter::repeat_n("item", 80)
.collect::<Vec<_>>()
.join(", ");
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();
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();
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;
for _ in 0..1000 {
if session
.resize_windows_incremental(&viewports, false)
.unwrap()
.is_some()
{
complete = true;
break;
}
}
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() {
let children = std::iter::repeat_n("item", 80)
.collect::<Vec<_>>()
.join(", ");
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();
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,
},
)]);
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());
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();
assert!(
session
.poll_tasks_incremental(&viewports)
.unwrap()
.is_none()
);
assert!(session.has_pending_refresh());
assert!(
session
.poll_tasks_incremental(&viewports)
.unwrap()
.is_none()
);
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
old_version
);
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\""
);
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() {
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;
let viewports = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 800.0,
height: 600.0,
},
)]);
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;
for _ in 0..1000 {
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() {
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}))"
);
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();
let root = session
.bridge
.prepare_frame(&mut session.candidate.vm)
.unwrap()
.unwrap();
let mut progress = session
.bridge
.begin_layout_frame(&mut session.candidate.vm, root)
.unwrap();
assert!(matches!(
progress,
crate::ui_bridge::LayoutProgress::Pending(_)
));
assert!(session.candidate.vm.has_suspended_call());
loop {
match progress {
crate::ui_bridge::LayoutProgress::Pending(pending) => {
progress = session
.bridge
.resume_layout_frame(&mut session.candidate.vm, *pending)
.unwrap();
}
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() {
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}))"
);
let (mut session, initial) = ReplSession::load(&source, "app.main", 269, 1).unwrap();
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() {
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();
initial.validate().unwrap();
let height: f32 = session
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
.unwrap()
.value
.parse()
.unwrap();
assert!(height > 20.0);
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() {
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();
initial.validate().unwrap();
let width = |session: &mut ReplSession, name| {
session
.execute(&format!("{name}.mounts().at(0).bounds().width"), "app.main")
.unwrap()
.value
};
assert_eq!(width(&mut session, "first"), "105");
assert_eq!(width(&mut session, "second"), "135");
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();
assert_eq!(width(&mut session, "first"), "150");
assert_eq!(width(&mut session, "second"), "90");
assert_eq!(width(&mut session, "fixed"), "60");
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() {
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();
initial.validate().unwrap();
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);
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() {
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();
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"
);
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() {
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();
initial.validate().unwrap();
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();
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() {
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();
initial.validate().unwrap();
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"),
] {
assert_eq!(
session.execute(expression, "app.main").unwrap().value,
expected
);
}
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"
);
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() {
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();
initial.validate().unwrap();
assert_eq!(
session
.execute("first.mounts().at(0).bounds().y", "app.main")
.unwrap()
.value,
"7"
);
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() {
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();
initial.validate().unwrap();
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}"
);
}
session
.execute("first.marginRight = 17", "app.main")
.unwrap();
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() {
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();
initial.validate().unwrap();
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}"
);
}
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")));
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() {
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();
initial.validate().unwrap();
assert_eq!(
session
.execute("text.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"160"
);
assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::Text { x, wrap_bounds: Some([width, _]), .. } if *x == 8.0 && *width == 140.0)));
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");
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() {
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();
let child = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive)
.unwrap();
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() {
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();
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"),
] {
assert_eq!(
session.execute(expression, "app.main").unwrap().value,
expected,
"{expression}"
);
}
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() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/box-layout.endor"),
"app.main",
302,
1,
)
.unwrap();
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() {
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();
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"
);
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() {
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();
initial.validate().unwrap();
assert_eq!(
session
.execute("button.mounts().count", "app.main")
.unwrap()
.value,
"2"
);
let buttons: Vec<_> = initial
.frame
.mounts
.iter()
.filter(|mount| mount.interactive)
.collect();
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]));
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"
);
let changed = session
.execute("first.camera.x = -40\nfirst.camera.zoom = 1", "app.main")
.unwrap()
.document
.unwrap();
changed.validate().unwrap();
let buttons: Vec<_> = changed
.frame
.mounts
.iter()
.filter(|mount| mount.interactive)
.collect();
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);
let shared = session
.execute(
"second.camera = first.camera\nfirst.camera.zoom = 2",
"app.main",
)
.unwrap()
.document
.unwrap();
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() {
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();
let button = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive)
.unwrap();
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]));
let point = crate::geometry::Point::new(55.0, 55.0);
assert_eq!(initial.frame.hit_test(point).unwrap().id, button.id);
let inner_id = initial.frame.camera_at(point).unwrap().id;
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,
};
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"
);
let document = session
.execute("hud.invalidate(\"paint\")", "app.main")
.unwrap()
.document
.unwrap();
let mut pixels = vec![0; 200 * 200];
crate::software_renderer::SoftwareRenderer::render(
&document.display_list(1.0).unwrap(),
&mut pixels,
200,
200,
)
.unwrap();
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() {
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();
let mut pixels = vec![0; 80 * 80];
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() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/scene-viewports.endor"),
"app.main",
307,
1,
)
.unwrap();
initial.validate().unwrap();
assert_eq!(
session
.execute("increment.mounts().count", "app.main")
.unwrap()
.value,
"2"
);
session
.execute(
"testScroll = ScrollView(100, 60, Row(0, 0, TextBlock(\"scroll\")))",
"app.main",
)
.unwrap();
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() {
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();
initial.validate().unwrap();
let document = session
.execute("view.mounts().at(0).fitView()", "app.main")
.unwrap()
.document
.unwrap();
document.validate().unwrap();
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
);
}
let rect = document
.frame
.mounts
.iter()
.find(|mount| mount.width == 100.0 && mount.height == 50.0)
.unwrap();
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() {
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();
let commands: Vec<_> = initial
.frame
.commands
.iter()
.filter_map(|command| {
if let RenderCommand::Scene3D { view, .. } = command {
Some(view)
} else {
None
}
})
.collect();
assert_eq!(commands.len(), 2);
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.));
assert!(matches!(
commands[1].camera.projection,
crate::scene3d::math::Projection::Orthographic { height: 4.0 }
));
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);
let updated = session
.execute("front.x=10\nperspective.camera.z=8", "app.main")
.unwrap()
.document
.unwrap();
updated.validate().unwrap();
let cameras: Vec<_> = updated
.frame
.commands
.iter()
.filter_map(|command| {
if let RenderCommand::Scene3D { view, .. } = command {
Some(view.camera.position.z)
} else {
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() {
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();
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;
let mount = frame
.mounts
.iter()
.find(|mount| mount.scene3d_viewport)
.unwrap();
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();
assert_eq!(
session.execute("mesh.color", "app.main").unwrap().value,
"\"#00FF00\""
);
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() {
let (mut session, document) = ReplSession::load(
include_str!("../../../examples/scene3d.endor"),
"app.main",
311,
1,
)
.unwrap();
document.validate().unwrap();
session.execute("actions.rotate()", "app.main").unwrap();
assert_eq!(
session.execute("cube.rotationY", "app.main").unwrap().value,
"40"
);
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();
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:#}"
);
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() {
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();
initial.validate().unwrap();
let button = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive && mount.panel.is_some())
.unwrap();
assert_eq!((button.width, button.height), (100., 100.));
let point = button
.panel
.as_ref()
.unwrap()
.project_point(crate::geometry::Point::new(50., 50.))
.unwrap();
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"
);
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];
crate::software_renderer::SoftwareRenderer::render(
&initial.display_list(1.).unwrap(),
&mut pixels,
200,
200,
)
.unwrap();
assert_eq!(pixels[100 * 200 + 100], 0xFF0000);
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,
};
session.dispatch(&input).unwrap();
assert_eq!(
session.execute("count.value", "app.main").unwrap().value,
"1"
);
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);
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();
blocked.validate().unwrap();
let button = blocked
.frame
.mounts
.iter()
.find(|mount| mount.panel.is_some() && mount.interactive)
.unwrap();
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() {
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();
initial.validate().unwrap();
let inner = initial
.frame
.mounts
.iter()
.find(|mount| mount.scene3d_viewport && mount.panel.is_some())
.unwrap();
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);
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();
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() {
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();
assert!(
format!("{error:#}").contains("cyclic Block composition"),
"{error:#}"
);
let mut source = String::from("seed=Button(\"deep\")\n");
for index in 0..9 {
let child = if index == 0 {
"seed".to_string()
} else {
format!("view{}", index - 1)
};
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"));
}
source.push_str("page=Page()\npage.render(view8)\n");
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() {
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();
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"
);
let mounts: Vec<_> = initial
.frame
.mounts
.iter()
.filter(|mount| mount.panel.is_some() && mount.interactive)
.collect();
let first = mounts[0]
.panel
.as_ref()
.unwrap()
.project_point(crate::geometry::Point::new(50., 40.))
.unwrap();
let second = mounts[1]
.panel
.as_ref()
.unwrap()
.project_point(crate::geometry::Point::new(100., 60.))
.unwrap();
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);
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() {
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.,
},
)]);
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()
);
}
assert!(
document
.frame
.mounts
.iter()
.any(|mount| mount.panel.is_some() && mount.canvas_viewport)
);
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"
);
let mut pixels = vec![0; 1024 * 800];
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() {
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",
] {
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();
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"),
] {
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();
assert!(vertical_width <= 244.0);
assert!(horizontal_width > vertical_width);
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() {
let mut session = ReplSession::new(262).unwrap();
session
.execute(
"scroller = ScrollBlock(20, 10, TextBlock(\"default\"))",
"app.main",
)
.unwrap();
assert_eq!(
session.execute("scroller.axis", "app.main").unwrap().value,
"\"both\""
);
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");
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() {
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();
initial.validate().unwrap();
assert_eq!(
session
.execute("scroller.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"120"
);
let width: f32 = session
.execute("text.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value
.parse()
.unwrap();
assert!(width <= 100.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() {
let mut session = ReplSession::new(241).unwrap();
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));
session
.execute("last.mounts().at(0).scrollIntoView()", "app.main")
.unwrap();
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"
);
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() {
let mut session = ReplSession::new(237).unwrap();
session
.execute(
r#"button = Button("event")
clicked = button.click()
dispatched = Event("click")
returned = button.dispatch(dispatched)"#,
"app.main",
)
.unwrap();
assert_eq!(
session
.execute("ui.isInstance(clicked, Event)", "app.main")
.unwrap()
.value,
"true"
);
assert_eq!(
session.execute("clicked.kind", "app.main").unwrap().value,
"\"click\""
);
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 {
target: ObjectRef,
signature: endor_core::CallSignature,
}
impl endor_core::NativeCallable for VisualRedirect {
/// 返回原方法签名副本,使转发器仍参与同一参数/返回/副作用契约验证。
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(self.signature.clone())
}
/// 不直接递归执行 target;返回 Invoke 请求,由 VM 在其正常调用栈/预算中处理参数和结果。
fn invoke(
&self,
_: &mut endor_core::NativeContext<'_>,
arguments: &[Value],
) -> std::result::Result<endor_core::NativeResult, VmError> {
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() {
let mut session = ReplSession::new(238).unwrap();
session.execute("second = Window()", "app.main").unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(window) = session
.candidate
.vm
.dict_get(globals, &Value::Text("second".into()))
.unwrap()
.unwrap()
else {
panic!("window")
};
let vm = &mut session.candidate.vm;
let desktop = vm
.payload::<crate::window::ManagedWindow>(window, "Window")
.unwrap()
.desktop;
let block = crate::ui_bridge::desktop_type(vm.object_store(), "Block", desktop).unwrap();
let function = vm
.object_store()
.lookup_method(block, "measure")
.unwrap()
.unwrap();
assert!(
crate::native_visual::is_intrinsic(vm.object_store(), function, "measure", desktop)
.unwrap()
);
assert!(
!crate::native_visual::is_intrinsic(vm.object_store(), function, "paint", desktop).unwrap()
);
let foreign = vm.new_object().unwrap();
let registry = vm.new_dict().unwrap();
vm.set_attr(foreign, "types", Value::Object(registry))
.unwrap();
assert!(
!crate::native_visual::is_intrinsic(vm.object_store(), function, "measure", foreign)
.unwrap()
);
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() {
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();
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"),
};
let base = get("base");
let hooks = get("Hooks");
let Value::Object(measure) = session.candidate.vm.get_attr(hooks, "measure").unwrap() else {
panic!("measure")
};
let Value::Object(paint) = session.candidate.vm.get_attr(hooks, "paint").unwrap() else {
panic!("paint")
};
session
.candidate
.vm
.define_method(base, "measure", measure)
.unwrap();
session.refresh_document().unwrap().unwrap();
assert_eq!(
session
.execute("rect.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"140"
);
let original = session
.candidate
.vm
.object_store()
.lookup_method(base, "paint")
.unwrap()
.unwrap();
let signature = session
.candidate
.vm
.object_store()
.native_signature(original)
.unwrap()
.unwrap();
session
.candidate
.vm
.replace_payload(
original,
endor_core::ContextNativeFunction::new(
"redirect paint",
VisualRedirect {
target: paint,
signature,
},
vec![paint],
),
)
.unwrap();
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() {
let mut session = ReplSession::new(236).unwrap();
session
.execute(
r#"
class Hooks { function onEvent(self: Object, event: Event): Unit {} }
base = Block
rect = RectBlock(20, 10)
"#,
"app.main",
)
.unwrap();
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"),
};
let base = get("base");
let hooks = get("Hooks");
let rect = get("rect");
let Value::Object(target) = session.candidate.vm.get_attr(hooks, "onEvent").unwrap() else {
panic!("onEvent")
};
let vm = &mut session.candidate.vm;
let function = vm
.object_store()
.lookup_method(base, "onEvent")
.unwrap()
.unwrap();
let mut signature = vm
.object_store()
.native_signature(function)
.unwrap()
.unwrap();
signature.effect = endor_core::CallEffect::Mutating;
let mut cache = crate::snapshot::RenderCache::default();
let initial = cache.snapshot(vm.object_store(), rect).unwrap();
assert!(!initial.interactive);
vm.take_changes();
vm.replace_payload(
function,
endor_core::ContextNativeFunction::new(
"redirect event",
VisualRedirect { target, signature },
vec![target],
),
)
.unwrap();
cache.invalidate(vm.take_changes());
let updated = cache.snapshot(vm.object_store(), rect).unwrap();
assert!(updated.interactive);
assert_ne!(initial.protocol_key, updated.protocol_key);
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() {
let mut session = ReplSession::new(234).unwrap();
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();
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),
] {
let Value::Object(function) = session
.candidate
.vm
.dict_get(globals, &Value::Text(name.into()))
.unwrap()
.unwrap()
else {
panic!("function")
};
assert_eq!(
session
.candidate
.vm
.object_store()
.call_effect(function)
.unwrap(),
expected
);
}
let Value::Object(model) = session
.candidate
.vm
.dict_get(globals, &Value::Text("model".into()))
.unwrap()
.unwrap()
else {
panic!("model")
};
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)
);
session.candidate.vm.release(bound).unwrap();
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);
let Value::Object(forward) = session
.candidate
.vm
.dict_get(globals, &Value::Text("forward".into()))
.unwrap()
.unwrap()
else {
panic!("forward")
};
session.execute("simple = impure", "app.main").unwrap();
assert_eq!(session.candidate.vm.call_effect(forward).unwrap(), None);
session.execute("simple = original", "app.main").unwrap();
assert_eq!(
session.candidate.vm.call_effect(forward).unwrap(),
Some(endor_core::CallEffect::ReadOnly)
);
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() {
let mut session = ReplSession::new(233).unwrap();
session
.execute(
"rect = RectBlock(40, 20)\nroot = Column(0, 0, rect)",
"app.main",
)
.unwrap();
let globals = session.candidate.globals.unwrap();
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");
for name in ["paint", "measure", "onEvent"] {
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;
let storage = *vm
.object_store()
.attribute_path(rect)
.unwrap()
.last()
.unwrap();
let mut cache = crate::snapshot::RenderCache::default();
cache.snapshot(vm.object_store(), root).unwrap();
vm.take_changes();
cache.snapshot(vm.object_store(), root).unwrap();
assert_eq!(cache.visited, 0);
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() {
let mut session = ReplSession::new(232).unwrap();
session.execute("marker = ui.state(0)", "app.main").unwrap();
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)");
let error = session
.execute(&input, "app.main")
.err()
.expect("incompatible typed capability");
assert!(
error
.to_string()
.contains("trait Presentable method present"),
"{error:#}"
);
assert_eq!(
session
.execute(&format!("ui.implements({name}, Presentable)"), "app.main")
.unwrap()
.value,
"false"
);
assert_eq!(
session.execute("marker.value", "app.main").unwrap().value,
"0"
);
}
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();
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() {
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 == "普通对象的显式展示能力")));
let globals = session.candidate.globals.unwrap();
let Value::Object(product) = session
.candidate
.vm
.dict_get(globals, &Value::Text("product".into()))
.unwrap()
.unwrap()
else {
panic!("expected Product");
};
let Value::Object(view) = session
.candidate
.vm
.dict_get(globals, &Value::Text("view".into()))
.unwrap()
.unwrap()
else {
panic!("expected Block");
};
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"
);
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")
)
);
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() {
let mut session = ReplSession::new(231).unwrap();
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();
assert_eq!(
session.execute("marker.value", "app.main").unwrap().value,
"0"
);
assert_eq!(
session
.execute("ui.supports(data,Presentable)", "app.main")
.unwrap()
.value,
"false"
);
session
.execute(
"ui.implement(Data,Presentable)\ncustom = ui.open(data)",
"app.main",
)
.unwrap();
assert_eq!(
session.execute("marker.value", "app.main").unwrap().value,
"1"
);
let before = session.documents.clone();
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() {
let mut session = ReplSession::new(220).unwrap();
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"
);
session.execute("model.width = 140", "app.main").unwrap();
assert_eq!(
session.execute("rect.width", "app.main").unwrap().value,
"140"
);
session.execute("first = Page()\nfirst.render(rect)\nother = Window()\nsecond = Page()\nsecond.open(other)\nsecond.render(rect)", "app.main").unwrap();
let updated = session
.execute("model.width = 260", "app.main")
.unwrap()
.document
.unwrap();
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));
}
session
.execute("token.disconnect()\nmodel.width = 360", "app.main")
.unwrap();
assert_eq!(
session.execute("rect.width", "app.main").unwrap().value,
"260"
);
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() {
let mut session = ReplSession::new(222).unwrap();
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();
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() {
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();
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);
assert!(
session
.execute("42", "app.main")
.unwrap()
.document
.is_none()
);
assert_eq!(count(&mut session), baseline);
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"
);
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() {
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;
session.execute("mountCycle()", "app.main").unwrap();
session.poll_tasks().unwrap();
assert_eq!(
session.execute("phase.value", "app.main").unwrap().value,
"1"
);
assert_eq!(
session
.execute("pending.value.status()", "app.main")
.unwrap()
.value,
"\"running\""
);
let globals = session.candidate.globals.unwrap();
let Value::Object(holder) = session
.candidate
.vm
.dict_get(globals, &Value::Text("holder".into()))
.unwrap()
.unwrap()
else {
panic!("holder")
};
let Value::Object(page) = session.candidate.vm.get_attr(holder, "value").unwrap() else {
panic!("page")
};
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();
let rect_weak = session.candidate.vm.object_store().downgrade(rect).unwrap();
session.execute("holder.value.close()", "app.main").unwrap();
assert_eq!(
session
.execute("pending.value.status()", "app.main")
.unwrap()
.value,
"\"running\""
);
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() {
let mut session = ReplSession::new(221).unwrap();
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();
let globals = session.candidate.globals.unwrap();
let Value::Object(target) = session
.candidate
.vm
.dict_get(globals, &Value::Text("rect".into()))
.unwrap()
.unwrap()
else {
panic!("expected Block");
};
let weak = session
.candidate
.vm
.object_store()
.downgrade(target)
.unwrap();
session
.candidate
.vm
.dict_delete(globals, &Value::Text("rect".into()))
.unwrap();
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"
);
assert!(
session
.execute(
"other = RectBlock(1,20)\nbadToken = other.bind(\"width\",model.bad)",
"app.main"
)
.is_err()
);
assert!(session.is_faulted());
let Value::Object(model) = session
.candidate
.vm
.dict_get(globals, &Value::Text("model".into()))
.unwrap()
.unwrap()
else {
panic!("expected model");
};
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() {
let mut session = ReplSession::new(21).unwrap();
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();
let updated = session
.execute("button.click()", "app.main")
.unwrap()
.document
.unwrap();
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() {
let mut session = ReplSession::new(22).unwrap();
session
.execute(
"card = ui.rect(10, 20)\npage = ui.page()\npage.render(card)",
"app.main",
)
.unwrap();
let globals = session.candidate.globals.unwrap();
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();
session.execute("page.close()", "app.main").unwrap();
session
.execute("card.setColor(\"#FFEEDD\")", "app.main")
.unwrap();
assert!(
session
.execute("page.open()", "app.main")
.unwrap()
.document
.is_some()
);
session.execute("page.close()", "app.main").unwrap();
session
.candidate
.vm
.dict_delete(globals, &Value::Text("page".into()))
.unwrap();
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() {
let mut session = ReplSession::new(10).unwrap();
session.execute("card = ui.rect(10, 20)\nother = card\ncontainer = ui.column(0, 0, other)\npage = ui.page()\npage.render(container)", "app.main").unwrap();
let identity = session.execute("other", "app.main").unwrap().value;
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
);
let frame = updated.document.unwrap().frame;
assert!(matches!(
frame.commands[0],
RenderCommand::RoundedRect {
width: 640.0,
height: 200.0,
..
}
));
assert!(
session
.execute("card = ui.column(0, 0, other)", "app.main")
.is_err()
);
assert!(
session
.execute("other", "app.main")
.unwrap()
.document
.is_none()
);
let unmounted = session
.execute("page.unmount()", "app.main")
.unwrap()
.document
.unwrap();
assert!(unmounted.frame.commands.is_empty());
assert!(
session
.execute("card = ui.rect(80, 50)", "app.main")
.unwrap()
.document
.is_none()
);
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() {
let mut session = ReplSession::new(7).unwrap();
let created = session
.execute(
r##"title = ui.text("你好,Tessera", 2, "#243047")"##,
"app.main",
)
.unwrap();
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
);
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")
);
assert_eq!(rendered.root.as_ref().unwrap().kind, "repl-session");
assert!(
session
.execute(r#"title = ui.text("changed")"#, "app.main")
.unwrap()
.document
.is_some()
);
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();
session
.execute("value = 1\npage = ui.page()", "app.main")
.unwrap();
assert!(session.execute("value = 2\nmissing", "app.main").is_err());
assert_eq!(session.execute("value", "app.main").unwrap().value, "1");
assert!(
session
.execute(
"value = 3\npage.render(ui.text(\"discarded\"))\nui.rect(-1, 1, 0)",
"app.main"
)
.is_err()
);
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"
)));
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() {
let document = compile(PAGE, "app.main", 9).unwrap();
assert_eq!(document.source, PAGE);
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);
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))
"##;
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() {
for ending in ["\n", "\r\n", "\r"] {
let source = format!(
"let title = ui.text(\"中文\"){ending}// comment{ending}let broken = missing{ending}ui.page().render(title)"
);
let error = compile(&source, "app.main", 27).unwrap_err();
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();
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() {
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"))"#;
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() {
let mut state = NamespaceState {
submission: 1,
..NamespaceState::default()
};
state
.commit(1, compile(PAGE, "app.main", 1).unwrap())
.unwrap();
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()",
] {
state.submission = state.next_submission().unwrap();
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() {
let mut source = String::new();
for index in 0..=ui_bridge::MAX_PAGES {
source.push_str(&format!("let page{index} = ui.page()\n"));
}
source.push_str("page0.render(ui.text(\"x\"))");
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() {
let mut source = "function text0(): Text { return \"Hello Endor\" }\n".to_owned();
for index in 1..=16 {
source.push_str(&format!(
"function text{index}(): Text {{ let unused = text{}() return text{}() }}\n",
index - 1,
index - 1
));
}
source.push_str("let title = ui.text(text16())\nui.page().render(title)");
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() {
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() {
let first = compile(PAGE, "app.main", 1).unwrap();
let second = compile(PAGE, "app.main", 2).unwrap();
let mut tools = crate::developer_tools::DeveloperToolsState::default();
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() {
let document = compile(EXAMPLE, "app.main", 1).unwrap();
assert_eq!(document.source, EXAMPLE);
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,
..
}
));
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() {
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, .. }
));
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() {
for ending in ["\r\n", "\r"] {
let source = EXAMPLE.replace('\n', ending);
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() {
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() {
let error = compile(
"function main(): View {\n let title = ui.text(\"中文\")\n return missing\n}",
"app.main",
17,
)
.unwrap_err();
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() {
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 {
let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned();
for index in 1..=levels {
let previous = index - 1;
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"));
}
}
source.push_str(&format!(
"function main(): View {{ return node{levels}() }}"
));
source
}
#[test]
/// 不共享子项的树先触发 4096 构造节点预算;共享树对象较少但展开出现位置更多。
/// 后者需报告 expanded Block graph,证明两类预算分别约束对象分配和渲染规模。
fn construction_and_expanded_layout_have_separate_budgets() {
let error = compile(&nested_functions(12, false), "app.main", 1).unwrap_err();
assert!(error.to_string().contains("4096 node budget"), "{error:#}");
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();
for index in 1..=64 {
source.push_str(&format!(
"function node{index}(): View {{ return ui.column(0, 0, node{}()) }}\n",
index - 1
));
}
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();
state.submission = state.next_submission().unwrap();
assert_eq!(
state
.commit(state.submission, compile(EXAMPLE, "app.main", 1).unwrap())
.unwrap(),
Some(1)
);
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;
let candidate = compile(EXAMPLE, "app.main", stale).unwrap();
state.submission = state.next_submission().unwrap();
assert!(state.commit(stale, candidate.clone()).unwrap().is_none());
let deleted = state.clear().unwrap();
assert!(state.commit(stale, candidate).unwrap().is_none());
assert_eq!(state.revision, deleted);
assert!(state.document.source.is_empty());
state.submission = state.next_submission().unwrap();
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 {
let globals = session.candidate.globals.unwrap();
let Some(Value::Object(log)) = session
.candidate
.vm
.dict_get(globals, &Value::Text("log".into()))
.unwrap()
else {
panic!("log")
};
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() {
let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 701, 1).unwrap();
assert_eq!(exit_log(&session), Value::Number(0_i64.into()));
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()));
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() {
let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 702, 1).unwrap();
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() {
let source = EXIT_SOURCE.replace("self.log.increment()", "ui.sleep(ui,\"late\",30000)");
let (mut session, _) = ReplSession::load(&source, "app.main", 703, 1).unwrap();
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() {
for (mode, expected) in [
("window", (400.0, 300.0)),
("vertical", (400.0, 710.0)),
("horizontal", (910.0, 300.0)),
] {
let source = format!(
r#"page=Page()
page.mode="{mode}"
page.gap=10
page.render(RectBlock(450,350))
page.append(RectBlock(450,350))"#
);
let (mut session, document) = ReplSession::load_with_viewport(
&source,
"app.main",
1,
300,
Some(crate::layout::Size {
width: 400.0,
height: 300.0,
}),
)
.unwrap();
let root = &document.frame.mounts[0];
assert_eq!((root.width, root.height), expected, "{mode}");
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);
}
if mode == "horizontal" {
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() {
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();
let mounts: Vec<_> = initial
.frame
.mounts
.iter()
.filter(|mount| mount.interactive)
.collect();
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);
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)
);
let old_page = next.frame.surface.page.clone();
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);
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() {
let mut session = ReplSession::new_with_viewport(
302,
Some(crate::layout::Size {
width: 1024.0,
height: 800.0,
}),
)
.unwrap();
let mut document = session
.execute("ui.newTab()", "app.main")
.unwrap()
.document
.unwrap();
assert_eq!(document.frame.surface.mode, crate::page::Mode::Window);
assert!(
document
.frame
.mounts
.iter()
.any(|mount| mount.canvas_viewport)
);
for (label, scrolling) in [("窗口页面", false), ("纵向延伸", true), ("横向延伸", true)]
{
let home_tab = document
.frame
.tabs
.iter()
.find(|tab| tab.title == "自由画布")
.unwrap()
.id;
let point = document
.frame
.commands
.iter()
.find_map(|command| match command {
RenderCommand::Text { x, y, value, .. } if value == label => {
Some(crate::geometry::Point::new(x + 2.0, y + 2.0))
}
_ => None,
})
.unwrap();
let mount = document
.frame
.hit_test(point)
.expect("button label must be clickable")
.id;
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();
assert_eq!(
document.frame.surface.mode,
crate::page::Mode::Window,
"{label}"
);
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();
}
}