7656 lines
258 KiB
Rust
7656 lines
258 KiB
Rust
//! 运行时集成回归与示例验收。测试经真实 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();
|
||
}
|
||
}
|