3042 lines
107 KiB
Rust
3042 lines
107 KiB
Rust
//! Compile complete source in a fresh VM or retain a worker-owned Console VM.
|
|
//! Console retains mounted native references and publishes immutable frames after successful inputs.
|
|
use anyhow::{Result, bail};
|
|
use endor_core::{ExecutionLimits, ObjectRef, Value, Vm, VmError};
|
|
use frontend::{
|
|
EndorReader, SourceUnit, Span,
|
|
ast::{AstRoot, Decl, DeclKind, Expr, ExprKind, Spanned, Stmt, StmtKind, visitor::Visitor},
|
|
};
|
|
use std::collections::BTreeSet;
|
|
|
|
use crate::{
|
|
document::{CodeNode, RenderDocument},
|
|
endor_script::{self, SourcePlan},
|
|
layout, ui_bridge,
|
|
};
|
|
|
|
pub const MAX_SOURCE_BYTES: usize = 1_048_576;
|
|
const LIMITS: ExecutionLimits = ExecutionLimits {
|
|
max_frames: 128,
|
|
max_instructions: 1_000_000,
|
|
};
|
|
const MAX_AST_NODES: usize = 10_000;
|
|
const MAX_AST_DEPTH: usize = 64;
|
|
|
|
pub fn compile(source: &str, namespace: &str, submission: u64) -> Result<RenderDocument> {
|
|
if source.len() > MAX_SOURCE_BYTES {
|
|
bail!("source exceeds the 1 MiB limit");
|
|
}
|
|
let source = EndorReader::from_text(format!("{namespace} [submission {submission}]"), source);
|
|
let plan = endor_script::parse(&source)?;
|
|
let mut budget = AstBudget::default();
|
|
for ast in plan.roots() {
|
|
budget.visit_ast_root(ast)?;
|
|
}
|
|
let compiled = compile_plan(&source, &plan)?;
|
|
let root = architecture(&source, &plan, namespace, submission);
|
|
let mut candidate = Candidate::new()?;
|
|
let result: Result<RenderDocument> = (|| {
|
|
let globals = candidate.vm.new_dict()?;
|
|
candidate.globals = Some(globals);
|
|
candidate.roots.push(globals);
|
|
let bridge = ui_bridge::install(&mut candidate.vm, &mut candidate.roots)?;
|
|
bridge.install_globals(&mut candidate.vm, globals)?;
|
|
let limits = match &plan {
|
|
SourcePlan::Module(_) => LIMITS,
|
|
SourcePlan::Script(_) => ExecutionLimits {
|
|
max_instructions: LIMITS.max_instructions / compiled.len().max(1),
|
|
..LIMITS
|
|
},
|
|
};
|
|
for template in &compiled {
|
|
let code = template.load(&mut candidate.vm)?;
|
|
candidate.roots.push(code);
|
|
let value = candidate
|
|
.vm
|
|
.execute_with_limits(code, globals, limits)
|
|
.map_err(|error| runtime_error(&candidate.vm, error))?;
|
|
candidate.vm.release_value(value)?;
|
|
}
|
|
let ui_root = match &plan {
|
|
SourcePlan::Script(_) => bridge
|
|
.rendered(&candidate.vm)?
|
|
.ok_or_else(|| anyhow::anyhow!("script must call ui.page().render(root)"))?,
|
|
SourcePlan::Module(_) => {
|
|
let entry = candidate
|
|
.vm
|
|
.dict_get(globals, &Value::Text("main".into()))?
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("declare main or render a page from a top-level script")
|
|
})?;
|
|
let Value::Object(entry) = entry else {
|
|
bail!("main must be a callable function");
|
|
};
|
|
let value = candidate
|
|
.vm
|
|
.call_with_limits(entry, &[], LIMITS)
|
|
.map_err(|error| runtime_error(&candidate.vm, error))?;
|
|
let snapshot = bridge
|
|
.rendered(&candidate.vm)
|
|
.and_then(|rendered| match rendered {
|
|
Some(root) => Ok(root),
|
|
None => ui_bridge::snapshot(&candidate.vm, &value),
|
|
});
|
|
candidate.vm.release_value(value)?;
|
|
snapshot?
|
|
}
|
|
};
|
|
let document = RenderDocument {
|
|
windows: std::collections::BTreeMap::new(),
|
|
source: source.text().to_owned(),
|
|
root: Some(root),
|
|
frame: layout::frame(&ui_root)?,
|
|
ui_root: Some(ui_root),
|
|
};
|
|
document.validate()?;
|
|
Ok(document)
|
|
})();
|
|
let cleanup = candidate.close();
|
|
match (result, cleanup) {
|
|
(Ok(document), Ok(())) => Ok(document),
|
|
(Err(error), Ok(())) | (Ok(_), Err(error)) => Err(error),
|
|
(Err(error), Err(cleanup)) => Err(error.context(format!("candidate cleanup: {cleanup}"))),
|
|
}
|
|
}
|
|
|
|
/// Evaluate exactly one expression in a disposable VM; never touch the published page.
|
|
pub fn evaluate(source: &str) -> Result<String> {
|
|
if source.len() > MAX_SOURCE_BYTES {
|
|
bail!("expression exceeds the 1 MiB limit");
|
|
}
|
|
let source = EndorReader::from_text("console expression", source);
|
|
let ast = frontend::parse_source_ast(&source, frontend::ParseMode::Expression)?;
|
|
AstBudget::default().visit_ast_root(&ast)?;
|
|
let compiled = compiler::compile_ast(&ast, &source, &compiler::CompileOptions::default())
|
|
.map_err(|error| source.annotate(error))?;
|
|
let mut candidate = Candidate::new()?;
|
|
let result = (|| {
|
|
let globals = candidate.vm.new_dict()?;
|
|
candidate.globals = Some(globals);
|
|
candidate.roots.push(globals);
|
|
let code = compiled.load(&mut candidate.vm)?;
|
|
candidate.roots.push(code);
|
|
let value = candidate
|
|
.vm
|
|
.execute_with_limits(code, globals, LIMITS)
|
|
.map_err(|error| runtime_error(&candidate.vm, error))?;
|
|
let rendered = match &value {
|
|
Value::Nil => "nil".to_owned(),
|
|
Value::Bool(v) => v.to_string(),
|
|
Value::Text(v) => format!("{v:?}"),
|
|
Value::Number(v) => v.to_string(),
|
|
Value::Object(v) => candidate.vm.repr(*v)?,
|
|
};
|
|
candidate.vm.release_value(value)?;
|
|
Ok(rendered)
|
|
})();
|
|
let cleanup = candidate.close();
|
|
match (result, cleanup) {
|
|
(Ok(value), Ok(())) => Ok(value),
|
|
(Err(e), _) | (_, Err(e)) => Err(e),
|
|
}
|
|
}
|
|
|
|
fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result<Vec<compiler::CompiledCode>> {
|
|
compile_plan_with_globals(
|
|
source,
|
|
plan,
|
|
std::iter::once("ui")
|
|
.chain(ui_bridge::TYPE_NAMES.iter().copied())
|
|
.chain(ui_bridge::QUERY_NAMES.iter().copied())
|
|
.map(str::to_owned)
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
fn compile_plan_with_globals(
|
|
source: &SourceUnit,
|
|
plan: &SourcePlan,
|
|
mut globals: BTreeSet<String>,
|
|
) -> Result<Vec<compiler::CompiledCode>> {
|
|
// Match Aster module behavior for function forward references. Bindings are
|
|
// added in source order; every executable statement still uses Aster's own
|
|
// single-input Interactive compiler and the candidate's shared globals.
|
|
for root in plan.roots() {
|
|
if let AstRoot::Interactive(input) = root {
|
|
for statement in &input.statements {
|
|
if let StmtKind::Declaration(decl) = &statement.kind {
|
|
let name = match &decl.kind {
|
|
DeclKind::Function(function) => &function.name,
|
|
DeclKind::Class(class) => &class.name,
|
|
};
|
|
globals.insert(name.text.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut compiled = Vec::with_capacity(plan.roots().len());
|
|
for root in plan.roots() {
|
|
let options = compiler::CompileOptions {
|
|
globals: globals.iter().cloned().collect(),
|
|
};
|
|
compiled.push(
|
|
compiler::compile_ast(root, source, &options)
|
|
.map_err(|error| source.annotate(error))?,
|
|
);
|
|
if let AstRoot::Interactive(input) = root {
|
|
for statement in &input.statements {
|
|
if let StmtKind::Binding(binding) = &statement.kind {
|
|
globals.insert(binding.name.text.clone());
|
|
} else if let StmtKind::Assignment(assignment) = &statement.kind {
|
|
globals.insert(assignment.name.text.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(compiled)
|
|
}
|
|
|
|
/// One real VM, owned only by the existing execution worker. No history replay.
|
|
/// The UI bridge shares the same managed object heap and publishes atomic frames.
|
|
pub struct ReplSession {
|
|
candidate: Candidate,
|
|
bridge: ui_bridge::UiBridge,
|
|
pub id: u64,
|
|
inputs: usize,
|
|
ast_nodes: usize,
|
|
source: String,
|
|
nodes: Vec<CodeNode>,
|
|
faulted: bool,
|
|
program_root: Option<CodeNode>,
|
|
documents: std::collections::BTreeMap<u64, RenderDocument>,
|
|
}
|
|
pub struct ReplResult {
|
|
pub value: String,
|
|
pub document: Option<RenderDocument>,
|
|
pub bindings: usize,
|
|
pub input: usize,
|
|
}
|
|
impl ReplSession {
|
|
/// Compile once into an isolated candidate, then keep its actual heap and
|
|
/// callback environments alive after the first successful publication.
|
|
pub fn load(
|
|
source: &str,
|
|
namespace: &str,
|
|
submission: u64,
|
|
id: u64,
|
|
) -> Result<(Self, RenderDocument)> {
|
|
Self::load_with_viewport(source, namespace, submission, id, None)
|
|
}
|
|
|
|
pub fn load_with_viewport(
|
|
source: &str,
|
|
namespace: &str,
|
|
submission: u64,
|
|
id: u64,
|
|
viewport: Option<crate::layout::Size>,
|
|
) -> Result<(Self, RenderDocument)> {
|
|
if source.len() > MAX_SOURCE_BYTES {
|
|
bail!("source exceeds the 1 MiB limit");
|
|
}
|
|
let unit = EndorReader::from_text(format!("{namespace} [submission {submission}]"), source);
|
|
let plan = endor_script::parse(&unit)?;
|
|
let mut budget = AstBudget::default();
|
|
for ast in plan.roots() {
|
|
budget.visit_ast_root(ast)?;
|
|
}
|
|
let compiled = compile_plan(&unit, &plan)?;
|
|
let mut runtime = Self::new(id)?;
|
|
runtime
|
|
.bridge
|
|
.set_viewport(&mut runtime.candidate.vm, viewport)?;
|
|
let globals = runtime.candidate.globals.expect("program globals");
|
|
let limits = if matches!(plan, SourcePlan::Script(_)) {
|
|
ExecutionLimits {
|
|
max_instructions: LIMITS.max_instructions / compiled.len().max(1),
|
|
..LIMITS
|
|
}
|
|
} else {
|
|
LIMITS
|
|
};
|
|
for template in &compiled {
|
|
let code = template.load(&mut runtime.candidate.vm)?;
|
|
let result = runtime
|
|
.candidate
|
|
.vm
|
|
.execute_with_limits(code, globals, limits)
|
|
.map_err(|error| runtime_error(&runtime.candidate.vm, error));
|
|
runtime.candidate.vm.release(code)?;
|
|
runtime.candidate.vm.release_value(result?)?;
|
|
}
|
|
if matches!(plan, SourcePlan::Module(_)) {
|
|
let main = runtime
|
|
.candidate
|
|
.vm
|
|
.dict_get(globals, &Value::Text("main".into()))?
|
|
.ok_or_else(|| anyhow::anyhow!("declare a main function"))?;
|
|
let Value::Object(main) = main else {
|
|
bail!("main must be callable");
|
|
};
|
|
let value = runtime
|
|
.candidate
|
|
.vm
|
|
.call_with_limits(main, &[], LIMITS)
|
|
.map_err(|error| runtime_error(&runtime.candidate.vm, error))?;
|
|
let opened = if runtime.bridge.has_page()? {
|
|
Ok(())
|
|
} else {
|
|
runtime.bridge.open_value(&mut runtime.candidate.vm, &value)
|
|
};
|
|
runtime.candidate.vm.release_value(value)?;
|
|
opened?;
|
|
}
|
|
if !runtime.bridge.has_page()? {
|
|
bail!("script must call page.render(root) or ui.open(value)");
|
|
}
|
|
runtime.source = source.into();
|
|
runtime.ast_nodes = budget.nodes;
|
|
runtime.program_root = Some(architecture(&unit, &plan, namespace, submission));
|
|
let document = runtime
|
|
.refresh_document()?
|
|
.ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?;
|
|
runtime
|
|
.bridge
|
|
.reclaim(&mut runtime.candidate.vm, &mut runtime.candidate.roots)?;
|
|
Ok((runtime, document))
|
|
}
|
|
|
|
pub fn new_with_viewport(id: u64, viewport: Option<crate::layout::Size>) -> Result<Self> {
|
|
let mut runtime = Self::new(id)?;
|
|
runtime
|
|
.bridge
|
|
.set_viewport(&mut runtime.candidate.vm, viewport)?;
|
|
Ok(runtime)
|
|
}
|
|
|
|
pub fn new(id: u64) -> Result<Self> {
|
|
let mut candidate = Candidate::new()?;
|
|
let globals = candidate.vm.new_dict()?;
|
|
candidate.globals = Some(globals);
|
|
candidate.roots.push(globals);
|
|
let bridge = ui_bridge::install(&mut candidate.vm, &mut candidate.roots)?;
|
|
bridge.install_globals(&mut candidate.vm, globals)?;
|
|
Ok(Self {
|
|
candidate,
|
|
bridge,
|
|
id,
|
|
inputs: 0,
|
|
ast_nodes: 0,
|
|
source: String::new(),
|
|
nodes: Vec::new(),
|
|
faulted: false,
|
|
program_root: None,
|
|
documents: std::collections::BTreeMap::new(),
|
|
})
|
|
}
|
|
pub fn execute(&mut self, source: &str, namespace: &str) -> Result<ReplResult> {
|
|
if self.faulted {
|
|
bail!(
|
|
"runtime stopped after a callback or rendering failure; reset the session or replace its program"
|
|
);
|
|
}
|
|
if source.len() > MAX_SOURCE_BYTES {
|
|
bail!("input exceeds 1 MiB");
|
|
}
|
|
if self.inputs >= 256 || self.source.len() + source.len() + 80 > MAX_SOURCE_BYTES {
|
|
bail!("console session exceeds 256 inputs or 1 MiB; reset the session to continue");
|
|
}
|
|
let source = EndorReader::from_text(
|
|
format!("console session {} input {}", self.id, self.inputs + 1),
|
|
source,
|
|
);
|
|
let plan = endor_script::parse(&source)?;
|
|
let mut budget = AstBudget::default();
|
|
for ast in plan.roots() {
|
|
budget.visit_ast_root(ast)?;
|
|
}
|
|
if self.ast_nodes + budget.nodes > MAX_AST_NODES {
|
|
bail!("console session exceeds 10000 AST nodes; reset the session to continue");
|
|
}
|
|
let globals = self.candidate.globals.expect("live console globals");
|
|
let names = self
|
|
.candidate
|
|
.vm
|
|
.dict_items(globals)?
|
|
.iter()
|
|
.filter_map(|(key, _)| {
|
|
if let Value::Text(name) = key {
|
|
Some(name.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
let compiled = compile_plan_with_globals(&source, &plan, names)?;
|
|
// Parsing/compilation finish before any new statement runs. Successful earlier
|
|
// statements in a runtime-failed input remain live, as in an ordinary REPL.
|
|
self.bridge.discard_pending(&mut self.candidate.vm)?;
|
|
self.bridge
|
|
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
|
|
self.inputs += 1;
|
|
self.ast_nodes += budget.nodes;
|
|
if let Some(root) = self.program_root.take() {
|
|
self.nodes.push(root);
|
|
}
|
|
if !self.source.is_empty() && !self.source.ends_with('\n') {
|
|
self.source.push('\n');
|
|
}
|
|
let offset = line_starts(&self.source).len();
|
|
self.source
|
|
.push_str(&format!("// Console input {}\n", self.inputs));
|
|
self.source.push_str(source.text());
|
|
if !self.source.ends_with('\n') {
|
|
self.source.push('\n');
|
|
}
|
|
let mut node = architecture(
|
|
&source,
|
|
&plan,
|
|
&format!("{namespace}:console-{}", self.id),
|
|
self.inputs as u64,
|
|
);
|
|
node.name = format!("Input {}", self.inputs);
|
|
shift_node_lines(&mut node, offset);
|
|
self.nodes.push(node);
|
|
let result: Result<ReplResult> = (|| {
|
|
let limits = ExecutionLimits {
|
|
max_instructions: LIMITS.max_instructions / compiled.len().max(1),
|
|
..LIMITS
|
|
};
|
|
let mut rendered_value = "nil".to_owned();
|
|
for (ast, template) in plan.roots().iter().zip(&compiled) {
|
|
let code = template.load(&mut self.candidate.vm)?;
|
|
let executed = self
|
|
.candidate
|
|
.vm
|
|
.execute_with_limits(code, globals, limits)
|
|
.map_err(|error| runtime_error(&self.candidate.vm, error));
|
|
self.candidate.vm.release(code)?;
|
|
let value = executed?;
|
|
// Keep the actual last result accessible as `_`, including unnamed UI objects.
|
|
let display = if let AstRoot::Interactive(input) = ast {
|
|
match &input.statements[0].kind {
|
|
StmtKind::Binding(binding) => self
|
|
.candidate
|
|
.vm
|
|
.dict_get(globals, &Value::Text(binding.name.text.clone()))?
|
|
.unwrap_or_else(|| value.clone()),
|
|
StmtKind::Assignment(assignment) => self
|
|
.candidate
|
|
.vm
|
|
.dict_get(globals, &Value::Text(assignment.name.text.clone()))?
|
|
.unwrap_or_else(|| value.clone()),
|
|
_ => value.clone(),
|
|
}
|
|
} else {
|
|
value.clone()
|
|
};
|
|
let repr = value_repr(&self.candidate.vm, &display);
|
|
let retained =
|
|
self.candidate
|
|
.vm
|
|
.dict_set(globals, Value::Text("_".into()), display);
|
|
self.candidate.vm.release_value(value)?;
|
|
retained?;
|
|
rendered_value = repr?;
|
|
}
|
|
let document = self.refresh_document()?;
|
|
Ok(ReplResult {
|
|
value: rendered_value,
|
|
document,
|
|
bindings: self
|
|
.candidate
|
|
.vm
|
|
.dict_items(globals)?
|
|
.len()
|
|
.saturating_sub(1),
|
|
input: self.inputs,
|
|
})
|
|
})();
|
|
if result.is_err() {
|
|
self.take_window_commands()?;
|
|
self.bridge.discard_pending(&mut self.candidate.vm)?;
|
|
if let Some(node) = self.nodes.last_mut() {
|
|
node.name
|
|
.push_str(" (error; earlier statements may remain live)");
|
|
}
|
|
}
|
|
self.bridge
|
|
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
|
|
result
|
|
}
|
|
|
|
fn refresh_document(&mut self) -> Result<Option<RenderDocument>> {
|
|
let mut work = 0;
|
|
let mut changed = false;
|
|
let mut mutated_mounts = false;
|
|
let result = (|| {
|
|
loop {
|
|
let mut pass_changed = false;
|
|
for id in self.bridge.window_ids()? {
|
|
self.bridge.select_window(id)?;
|
|
while let Some(root) = self.bridge.prepare_frame(&mut self.candidate.vm)? {
|
|
mutated_mounts = true;
|
|
let frame = self
|
|
.bridge
|
|
.layout_frame(&mut self.candidate.vm, root.clone())?;
|
|
let mut document = RenderDocument {
|
|
windows: std::collections::BTreeMap::new(),
|
|
source: self.source.clone(),
|
|
root: Some(self.program_root.clone().unwrap_or_else(|| CodeNode {
|
|
id: format!("console-session-{}", self.id),
|
|
name: format!("Console session {}", self.id),
|
|
kind: "repl-session".into(),
|
|
start_line: 1,
|
|
end_line: self.source.lines().count(),
|
|
children: self.nodes.clone(),
|
|
})),
|
|
frame,
|
|
ui_root: root.clone(),
|
|
};
|
|
document.frame.session = self.id;
|
|
document.validate()?;
|
|
work += document.frame.mounts.len().max(1);
|
|
if work > 200_000 {
|
|
bail!(
|
|
"layout dependencies did not stabilize within the rendering work budget"
|
|
);
|
|
}
|
|
self.bridge.paint_frame(
|
|
&mut self.candidate.vm,
|
|
root.as_deref(),
|
|
&mut document.frame,
|
|
)?;
|
|
document.validate()?;
|
|
self.documents.insert(id, document);
|
|
pass_changed = true;
|
|
changed = true;
|
|
}
|
|
}
|
|
if !pass_changed {
|
|
break;
|
|
}
|
|
}
|
|
self.bridge.finish_closed(&mut self.candidate.vm)?;
|
|
let ids = self.bridge.window_ids()?;
|
|
self.documents.retain(|id, _| ids.contains(id));
|
|
if !changed {
|
|
return Ok(None);
|
|
}
|
|
let mut main = self
|
|
.documents
|
|
.get(&crate::window::MAIN_WINDOW)
|
|
.cloned()
|
|
.unwrap_or_else(|| RenderDocument {
|
|
source: self.source.clone(),
|
|
frame: crate::document::Frame {
|
|
session: self.id,
|
|
..crate::document::Frame::default()
|
|
},
|
|
..RenderDocument::default()
|
|
});
|
|
main.source = self.source.clone();
|
|
main.root = self
|
|
.documents
|
|
.values()
|
|
.find(|document| document.source == self.source)
|
|
.and_then(|document| document.root.clone())
|
|
.or(main.root);
|
|
main.windows = self
|
|
.documents
|
|
.iter()
|
|
.filter(|(id, _)| **id != crate::window::MAIN_WINDOW)
|
|
.map(|(id, document)| (*id, document.clone()))
|
|
.collect();
|
|
main.validate()?;
|
|
Ok(Some(main))
|
|
})();
|
|
self.bridge.select_window(crate::window::MAIN_WINDOW)?;
|
|
if result.is_err() && mutated_mounts {
|
|
self.faulted = true;
|
|
}
|
|
result
|
|
}
|
|
|
|
pub fn resize_windows(
|
|
&mut self,
|
|
viewports: &std::collections::BTreeMap<u64, crate::layout::Size>,
|
|
) -> Result<Option<RenderDocument>> {
|
|
if self.faulted {
|
|
bail!("runtime stopped after a callback or rendering failure");
|
|
}
|
|
let ids = self.bridge.window_ids()?;
|
|
if ids.len() == 1
|
|
&& let Some(viewport) = viewports.get(&crate::window::MAIN_WINDOW)
|
|
{
|
|
self.bridge.select_window(crate::window::MAIN_WINDOW)?;
|
|
return self.resize(Some(*viewport));
|
|
}
|
|
let mut changed = false;
|
|
for (id, viewport) in viewports {
|
|
if ids.contains(id) {
|
|
self.bridge.select_window(*id)?;
|
|
changed |= self
|
|
.bridge
|
|
.set_viewport(&mut self.candidate.vm, Some(*viewport))?;
|
|
}
|
|
}
|
|
self.bridge.select_window(crate::window::MAIN_WINDOW)?;
|
|
if !changed {
|
|
return Ok(None);
|
|
}
|
|
let result = self.refresh_document();
|
|
if result.is_err() {
|
|
self.take_window_commands()?;
|
|
self.bridge.discard_pending(&mut self.candidate.vm)?;
|
|
}
|
|
self.bridge
|
|
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
|
|
result
|
|
}
|
|
|
|
pub fn take_window_commands(&self) -> Result<Vec<crate::window::Command>> {
|
|
self.bridge.take_window_commands(&self.candidate.vm)
|
|
}
|
|
|
|
pub fn resize(
|
|
&mut self,
|
|
viewport: Option<crate::layout::Size>,
|
|
) -> Result<Option<RenderDocument>> {
|
|
if self.faulted {
|
|
bail!("runtime stopped after a callback or rendering failure");
|
|
}
|
|
if !self.bridge.set_viewport(&mut self.candidate.vm, viewport)? {
|
|
return Ok(None);
|
|
}
|
|
let result = self.refresh_document();
|
|
if result.is_err() {
|
|
self.take_window_commands()?;
|
|
self.bridge.discard_pending(&mut self.candidate.vm)?;
|
|
}
|
|
self.bridge
|
|
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
|
|
result
|
|
}
|
|
|
|
pub fn accepts_window(&self, id: u64) -> bool {
|
|
self.bridge.accepts_window(id)
|
|
}
|
|
|
|
pub fn is_faulted(&self) -> bool {
|
|
self.faulted
|
|
}
|
|
|
|
pub fn dispatch(&mut self, input: &crate::events::Input) -> Result<ReplResult> {
|
|
if self.faulted {
|
|
bail!("runtime stopped after a callback or rendering failure");
|
|
}
|
|
if input.session != self.id {
|
|
bail!("event belongs to another runtime session");
|
|
}
|
|
self.bridge.select_window(input.window)?;
|
|
let result = (|| {
|
|
self.bridge.dispatch(&mut self.candidate.vm, input)?;
|
|
let document = self.refresh_document()?;
|
|
Ok(ReplResult {
|
|
value: "nil".into(),
|
|
document,
|
|
bindings: self
|
|
.candidate
|
|
.vm
|
|
.dict_items(self.candidate.globals.expect("live globals"))?
|
|
.len()
|
|
.saturating_sub(1),
|
|
input: self.inputs,
|
|
})
|
|
})();
|
|
self.bridge.select_window(crate::window::MAIN_WINDOW)?;
|
|
if result.is_err() {
|
|
self.take_window_commands()?;
|
|
self.faulted = true;
|
|
self.bridge.discard_pending(&mut self.candidate.vm)?;
|
|
}
|
|
self.bridge
|
|
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
|
|
result
|
|
}
|
|
}
|
|
fn shift_node_lines(node: &mut CodeNode, offset: usize) {
|
|
node.start_line += offset;
|
|
node.end_line += offset;
|
|
for child in &mut node.children {
|
|
shift_node_lines(child, offset);
|
|
}
|
|
}
|
|
fn value_repr(vm: &Vm, value: &Value) -> Result<String> {
|
|
Ok(match value {
|
|
Value::Nil => "nil".into(),
|
|
Value::Bool(v) => v.to_string(),
|
|
Value::Text(v) => format!("{v:?}"),
|
|
Value::Number(v) => v.to_string(),
|
|
Value::Object(v) => vm.repr(*v)?,
|
|
})
|
|
}
|
|
|
|
struct Candidate {
|
|
vm: Vm,
|
|
roots: Vec<ObjectRef>,
|
|
globals: Option<ObjectRef>,
|
|
}
|
|
|
|
impl Candidate {
|
|
fn new() -> Result<Self> {
|
|
Ok(Self {
|
|
vm: Vm::new()?,
|
|
roots: Vec::new(),
|
|
globals: None,
|
|
})
|
|
}
|
|
|
|
fn close(&mut self) -> Result<()> {
|
|
if let Some(globals) = self.globals.take() {
|
|
self.vm.dict_clear(globals)?;
|
|
}
|
|
while let Some(root) = self.roots.pop() {
|
|
self.vm.release(root)?;
|
|
}
|
|
self.vm.collect_cycles();
|
|
if self.vm.object_count() != 0 {
|
|
bail!("candidate VM retained objects after cleanup");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for Candidate {
|
|
fn drop(&mut self) {
|
|
// Explicit close reports accounting errors; dropping the fresh VM is
|
|
// also the final cleanup boundary on every early return or unwind.
|
|
let _ = self.close();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error {
|
|
let locations = vm
|
|
.last_traceback()
|
|
.iter()
|
|
.map(|frame| {
|
|
format!(
|
|
"{}:{} in {} (instruction {})",
|
|
frame.filename, frame.first_line, frame.function, frame.instruction_offset
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
if locations.is_empty() {
|
|
error.into()
|
|
} else {
|
|
anyhow::anyhow!("{error}\n{locations}")
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct AstBudget {
|
|
depth: usize,
|
|
nodes: usize,
|
|
}
|
|
|
|
impl Visitor for AstBudget {
|
|
type Error = anyhow::Error;
|
|
fn enter_node(&mut self, _span: Span) -> Result<()> {
|
|
self.depth += 1;
|
|
self.nodes += 1;
|
|
if self.depth > MAX_AST_DEPTH || self.nodes > MAX_AST_NODES {
|
|
bail!("Aster AST exceeds the 64-level or 10000-node host budget");
|
|
}
|
|
Ok(())
|
|
}
|
|
fn leave_node(&mut self) {
|
|
self.depth -= 1;
|
|
}
|
|
}
|
|
|
|
fn architecture(
|
|
source: &SourceUnit,
|
|
plan: &SourcePlan,
|
|
namespace: &str,
|
|
submission: u64,
|
|
) -> CodeNode {
|
|
let mut tree = Architecture {
|
|
line_starts: line_starts(source.text()),
|
|
next_id: 0,
|
|
identity: format!("{namespace}@{submission}"),
|
|
};
|
|
let mut children = Vec::new();
|
|
for root in plan.roots() {
|
|
match root {
|
|
AstRoot::Module(program) => children.extend(
|
|
program
|
|
.declarations
|
|
.iter()
|
|
.map(|decl| tree.declaration(decl)),
|
|
),
|
|
AstRoot::Interactive(input) => {
|
|
children.extend(input.statements.iter().map(|stmt| tree.statement(stmt)))
|
|
}
|
|
AstRoot::Expression(expression) => children.push(tree.expression(expression)),
|
|
}
|
|
}
|
|
let roots = plan.roots();
|
|
let span = Span::new(
|
|
roots.first().map_or(0, |root| root.span().start),
|
|
roots.last().map_or(0, |root| root.span().end),
|
|
);
|
|
let kind = if matches!(plan, SourcePlan::Module(_)) {
|
|
"module"
|
|
} else {
|
|
"script"
|
|
};
|
|
tree.node(namespace.to_owned(), kind, span, children)
|
|
}
|
|
|
|
fn line_starts(source: &str) -> Vec<usize> {
|
|
let bytes = source.as_bytes();
|
|
let mut starts = vec![0];
|
|
for (offset, byte) in bytes.iter().enumerate() {
|
|
if *byte == b'\n' || (*byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n')) {
|
|
starts.push(offset + 1);
|
|
}
|
|
}
|
|
starts
|
|
}
|
|
|
|
struct Architecture {
|
|
line_starts: Vec<usize>,
|
|
next_id: usize,
|
|
identity: String,
|
|
}
|
|
|
|
impl Architecture {
|
|
fn node(&mut self, name: String, kind: &str, span: Span, children: Vec<CodeNode>) -> CodeNode {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let line = |offset| self.line_starts.partition_point(|start| *start <= offset);
|
|
CodeNode {
|
|
id: format!("{}:ast-{id}", self.identity),
|
|
name,
|
|
kind: kind.into(),
|
|
start_line: line(span.start),
|
|
end_line: line(span.end.saturating_sub(1).max(span.start)),
|
|
children,
|
|
}
|
|
}
|
|
|
|
fn declaration(&mut self, declaration: &Decl) -> CodeNode {
|
|
match &declaration.kind {
|
|
DeclKind::Function(function) => self.function(function),
|
|
DeclKind::Class(class) => {
|
|
let mut children: Vec<_> = class
|
|
.fields
|
|
.iter()
|
|
.map(|field| self.node(field.name.text.clone(), "field", field.span, vec![]))
|
|
.collect();
|
|
children.extend(class.methods.iter().map(|method| self.function(method)));
|
|
self.node(class.name.text.clone(), "class", class.span, children)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn function(&mut self, function: &frontend::ast::Function) -> CodeNode {
|
|
let children = function
|
|
.body
|
|
.statements
|
|
.iter()
|
|
.map(|stmt| self.statement(stmt))
|
|
.collect();
|
|
self.node(
|
|
function.name.text.clone(),
|
|
"function",
|
|
function.span,
|
|
children,
|
|
)
|
|
}
|
|
|
|
fn statement(&mut self, statement: &Stmt) -> CodeNode {
|
|
match &statement.kind {
|
|
StmtKind::MemberAssignment(assignment) => {
|
|
let object = self.expression(&assignment.object);
|
|
let value = self.expression(&assignment.value);
|
|
self.node(
|
|
assignment.name.text.clone(),
|
|
"member-assign",
|
|
statement.span,
|
|
vec![object, value],
|
|
)
|
|
}
|
|
StmtKind::Binding(binding) => {
|
|
let child = self.expression(&binding.value);
|
|
self.node(
|
|
binding.name.text.clone(),
|
|
"let",
|
|
statement.span,
|
|
vec![child],
|
|
)
|
|
}
|
|
StmtKind::Assignment(assignment) => {
|
|
let child = self.expression(&assignment.value);
|
|
self.node(
|
|
assignment.name.text.clone(),
|
|
"assign",
|
|
statement.span,
|
|
vec![child],
|
|
)
|
|
}
|
|
StmtKind::Return(returned) => {
|
|
let children = returned
|
|
.value
|
|
.iter()
|
|
.map(|expr| self.expression(expr))
|
|
.collect();
|
|
self.node("return".into(), "return", statement.span, children)
|
|
}
|
|
StmtKind::Expression(expr) => self.expression(expr),
|
|
StmtKind::Declaration(decl) => self.declaration(decl),
|
|
}
|
|
}
|
|
|
|
fn expression(&mut self, expr: &Expr) -> CodeNode {
|
|
let (name, kind, children) = match &expr.kind {
|
|
ExprKind::Name(name) => (name.clone(), "name", vec![]),
|
|
ExprKind::Number(value) => (value.clone(), "number", vec![]),
|
|
ExprKind::String(value) => (value.chars().take(40).collect(), "string", vec![]),
|
|
ExprKind::Symbol(name) => (name.text.clone(), "symbol", vec![]),
|
|
ExprKind::Unary { operand, .. } => {
|
|
("negate".into(), "unary", vec![self.expression(operand)])
|
|
}
|
|
ExprKind::Binary { left, right, .. } => (
|
|
"binary".into(),
|
|
"binary",
|
|
vec![self.expression(left), self.expression(right)],
|
|
),
|
|
ExprKind::Member { object, name } => {
|
|
(name.text.clone(), "member", vec![self.expression(object)])
|
|
}
|
|
ExprKind::Call { callee, arguments } => {
|
|
let mut children = vec![self.expression(callee)];
|
|
children.extend(arguments.iter().map(|expr| self.expression(expr)));
|
|
("call".into(), "call", children)
|
|
}
|
|
};
|
|
self.node(name, kind, expr.span, children)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::document::{NamespaceState, RenderCommand};
|
|
|
|
const EXAMPLE: &str = include_str!("../examples/hello.endor");
|
|
const PAGE: &str = include_str!("../examples/page.endor");
|
|
|
|
#[test]
|
|
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("nil", "app.main").unwrap();
|
|
session.candidate.vm.collect_cycles();
|
|
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
|
|
// An externally retained old leaf Mount must not retain the window context.
|
|
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]
|
|
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]
|
|
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: 120,
|
|
layout_version: initial.frame.layout_version,
|
|
mount: initial.frame.tabs[0].id,
|
|
kind: "tab-activate".into(),
|
|
point: None,
|
|
press_point: None,
|
|
};
|
|
let selected = session.dispatch(&input).unwrap().document.unwrap();
|
|
assert!(selected.frame.tabs[0].active);
|
|
assert!(selected.frame.layout_version > initial.frame.layout_version);
|
|
// Query the bridge directly: stale transport must be rejected before faulting a runtime.
|
|
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: 120,
|
|
layout_version: renamed.frame.layout_version,
|
|
mount: renamed.frame.tabs[1].id,
|
|
kind: "tab-close".into(),
|
|
point: None,
|
|
press_point: 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]
|
|
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]
|
|
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]
|
|
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]
|
|
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("window operations are forbidden")
|
|
);
|
|
}
|
|
|
|
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()
|
|
}
|
|
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,
|
|
press_point: None,
|
|
})
|
|
.unwrap()
|
|
.document
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
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")
|
|
));
|
|
assert_eq!(source_measure_stat(&mut session, "sourceMeasureHits"), 2);
|
|
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)
|
|
);
|
|
assert_eq!(source_measure_stat(&mut session, "sourceMeasureHits"), 1);
|
|
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1);
|
|
}
|
|
|
|
#[test]
|
|
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]
|
|
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]
|
|
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();
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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)",
|
|
"wrong result type",
|
|
),
|
|
(
|
|
"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]
|
|
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: 81,
|
|
layout_version: initial.frame.layout_version,
|
|
mount: circle.id,
|
|
kind: "click".into(),
|
|
point: Some(release),
|
|
press_point: Some(press),
|
|
})
|
|
.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]
|
|
fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() {
|
|
for (body, message) in [
|
|
("return 1", "hitTest must return 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: 82,
|
|
layout_version: initial.frame.layout_version,
|
|
mount: circle.id,
|
|
kind: "click".into(),
|
|
point: Some([64.0, 64.0]),
|
|
press_point: Some([64.0, 64.0]),
|
|
})
|
|
.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]
|
|
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)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
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]
|
|
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")
|
|
};
|
|
// No Console/global binding writes or focus changes: only the property
|
|
// read by paint changes, proving the paint dependency is sufficient.
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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());
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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: 41,
|
|
layout_version: document.frame.layout_version,
|
|
mount: mount.id,
|
|
kind: "click".into(),
|
|
point: None,
|
|
press_point: None,
|
|
})
|
|
.unwrap()
|
|
.document
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
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]
|
|
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]
|
|
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\""
|
|
);
|
|
// Reusing a completed event is legal; its previous dispatch has no live guard.
|
|
session
|
|
.execute("button.dispatch(result)", "app.main")
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
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]
|
|
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]
|
|
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,
|
|
press_point: 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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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());
|
|
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]
|
|
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()
|
|
);
|
|
// Separate VM instances render the same commands with independent
|
|
// managed object identities and presentation contexts.
|
|
assert_ne!(
|
|
document.frame.mounts[0].block_id,
|
|
wrapped.frame.mounts[0].block_id
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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
|
|
));
|
|
// Candidate::close already proved zero remaining VM objects. Both
|
|
// redraws must still work from native data at different display scales.
|
|
assert_eq!(document.display_list(1.0).unwrap().commands().len(), 5);
|
|
assert_eq!(document.display_list(2.0).unwrap().commands().len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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")
|
|
);
|
|
}
|
|
|
|
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]
|
|
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]
|
|
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]
|
|
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)
|
|
);
|
|
}
|
|
}
|