blob: f9607ac1993070c7b87fa0a2fbbc11ddc4689a93 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::Application;
use super::{close_all, support, timer};
use crate::support::scene::Node;
use maka_packages::Package;
use maka_plugin_core::{
composition::{Input, PresentationTarget, Target},
contract::{Cardinality, Contract, Import, Interface},
package::{EntryTarget, Implementation},
};
use maka_ui_runtime::{
ComponentCall, ComponentOutput, Emission, Frame, NativeCapability, NativeComponent,
NativeExport, NativeView,
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, rc::Rc};
const CHILD_EVENT: &str = "return frame({...state,count:state.count+1,revision:state.revision+1},[{name:'changed',value:state.count+1}]);";
const RECEIVE: &str = "return frame({...state,revision:state.revision+1,seen:[...(state.seen??[]),[event.child,event.name,event.key,event.event]],total:(state.total??0)+event.event});";
pub(super) fn fixture(
mount: &str,
event: &str,
update: &str,
receive: &str,
on: &str,
) -> (Input, Vec<Package>, Contract) {
let (mut input, mut packages) = support::composed_fixture(update, receive);
let old_counter = packages.pop().unwrap();
let old_root = packages.pop().unwrap();
let mut declaration = old_counter
.manifest()
.application
.as_ref()
.unwrap()
.declaration()
.clone();
let contract = &mut declaration
.entries
.get_mut(&EntryTarget::Tui)
.unwrap()
.exports
.get_mut("Counter")
.unwrap()
.contract;
let old_contract = serde_json::to_string(contract).unwrap();
let Interface::Component { events, .. } = &mut contract.interface else {
unreachable!()
};
*events = ["changed", "a", "b", "c"]
.into_iter()
.map(|name| (name.into(), json!({"type":"integer"}).into()))
.collect();
let contract = contract.clone();
let source = std::str::from_utf8(old_counter.file("index.mjs").unwrap()).unwrap()
.replace(&old_contract, &serde_json::to_string(&contract).unwrap())
.replace("function frame(state)", "function frame(state,events=[])")
.replace("actions:[],events:[],frame:", "actions:[],events,frame:")
.replace("mount(props){return frame({count:props.initial,label:props.label,revision:0});}", &format!("mount(props){{{mount}}}"))
.replace("event(state,event){return frame({...state,count:state.count+1,revision:state.revision+1});}", &format!("event(state,event,props){{{event}}}"));
let counter = support::package("dynamic.counter", declaration, Some(source));
let mut declaration = old_root
.manifest()
.application
.as_ref()
.unwrap()
.declaration()
.clone();
declaration
.entries
.get_mut(&EntryTarget::Tui)
.unwrap()
.imports
.get_mut("counter")
.unwrap()
.contract = contract.clone();
let source = std::str::from_utf8(old_root.file("index.mjs").unwrap())
.unwrap()
.replace("on:{},props:", &format!("on:{on},props:"));
let root = support::package("dynamic.app", declaration, Some(source));
input.instances.get_mut("app").unwrap().package = root.application_ref().into();
input.instances.get_mut("counter").unwrap().package = counter.application_ref().into();
packages.extend([root, counter]);
(input, packages, contract)
}
const MOUNT: &str = "return frame({count:props.initial,label:props.label,revision:0});";
fn application(
input: &Input,
packages: Vec<Package>,
timer: &tokio::runtime::Runtime,
) -> Application {
Application::new(
input,
packages,
timer.handle().clone(),
maka_ui_js::execution(Default::default()),
crate::support::scene::registry(PresentationTarget::Tui),
Default::default(),
)
.unwrap()
}
#[test]
fn child_events_use_public_schema_exact_slots_and_staged_parent_state() {
let timer = timer();
let (input, packages, _) = fixture(
MOUNT,
"return frame({...state,count:state.count+1,revision:state.revision+1},[{name:'changed',value:2},{name:'changed',value:3}]);",
support::CHILD_UPDATE,
RECEIVE,
"{changed:'notice'}",
);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
let left = app.child_mount(&root, "left").unwrap().clone();
let right = app.child_mount(&root, "right").unwrap().clone();
// A second root owns independent subscriptions even with identical providers.
let other = app.mount_launch(json!({})).unwrap();
app.dispatch(&left, 0, "inc", Value::Null).unwrap();
assert_eq!(app.frame(&root).unwrap().state["total"], 5);
assert_eq!(
app.frame(&root).unwrap().state["seen"],
json!([
["left", "changed", "notice", 2],
["left", "changed", "notice", 3]
])
);
assert_eq!(app.frame(&root).unwrap().revision, 2);
assert_eq!(app.frame(&left).unwrap().state["count"], 11);
assert_eq!(app.frame(&right).unwrap().revision, 0);
assert_eq!(app.frame(&other).unwrap().revision, 0);
// A raw payload cannot impersonate the adapter's component origin or key.
assert!(
app.dispatch(
&root,
2,
"notice",
json!({"kind":"component","child":"left","name":"changed","event":999})
)
.is_err()
);
app.retire(&root).unwrap();
for mount in app.retiring_mounts() {
app.close(&mount, Ok(())).unwrap();
}
assert_eq!(app.frame(&other).unwrap().revision, 0);
close_all(&mut app, &other);
}
#[test]
fn revoked_subscription_cannot_resurrect_queued_emission_after_same_batch_aba() {
let timer = timer();
let receive = "const next={...state,revision:state.revision+1}; if(event.key==='a')next.on={a:'a',c:'c'};else if(event.key==='c')next.on={a:'a',b:'b',c:'c'};else next.deliveries=(state.deliveries??0)+1;return frame(next);";
let event = "return frame({...state,revision:state.revision+1},state.revision===0?[{name:'a',value:1},{name:'c',value:1},{name:'b',value:1}]:[{name:'b',value:1}]);";
let (input, packages, _) = fixture(
MOUNT,
event,
support::CHILD_UPDATE,
receive,
"state.on??{a:'a',b:'b',c:'c'}",
);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
let left = app.child_mount(&root, "left").unwrap().clone();
app.dispatch(&left, 0, "inc", Value::Null).unwrap();
assert_eq!(app.frame(&root).unwrap().revision, 2);
assert_eq!(app.frame(&root).unwrap().state["deliveries"], Value::Null);
app.dispatch(&left, 1, "inc", Value::Null).unwrap();
assert_eq!(app.frame(&root).unwrap().state["deliveries"], 1);
assert_eq!(app.frame(&root).unwrap().revision, 3);
close_all(&mut app, &root);
}
#[test]
fn invalid_emissions_and_invalid_subscriptions_never_publish_partial_scene() {
let timer = timer();
for (on, event, initial_error) in [
("{unknown:'notice'}", CHILD_EVENT, true),
(
"{}",
"return frame({...state,revision:state.revision+1},[{name:'unknown',value:1}]);",
false,
),
(
"{}",
"return frame({...state,revision:state.revision+1},[{name:'changed',value:'bad'}]);",
false,
),
(
"{changed:'notice'}",
"return frame({...state,revision:state.revision+1},[{name:'changed',value:1},{name:'changed',value:'bad'}]);",
false,
),
] {
let (input, packages, _) = fixture(MOUNT, event, support::CHILD_UPDATE, RECEIVE, on);
let mut app = application(&input, packages, &timer);
if initial_error {
assert!(app.mount_launch(json!({})).is_err());
assert!(app.retiring_mounts().is_empty());
app.shutdown().unwrap();
} else {
let root = app.mount_launch(json!({})).unwrap();
let left = app.child_mount(&root, "left").unwrap().clone();
assert!(app.dispatch(&left, 0, "inc", Value::Null).is_err());
assert_eq!(app.frame(&left).unwrap().revision, 0);
assert_eq!(app.frame(&root).unwrap().revision, 0);
close_all(&mut app, &root);
}
}
}
#[test]
fn parent_event_sibling_failure_rolls_back_emitter_and_prior_parent_transitions() {
let timer = timer();
let (input, packages, _) = fixture(
MOUNT,
CHILD_EVENT,
"if(props.label==='Right 1')throw new Error('reject sibling');return frame({...state,label:props.label,revision:state.revision+1});",
"return frame({...state,version:state.version+1,revision:state.revision+1});",
"{changed:'notice'}",
);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
let left = app.child_mount(&root, "left").unwrap().clone();
let right = app.child_mount(&root, "right").unwrap().clone();
assert!(app.dispatch(&left, 0, "inc", Value::Null).is_err());
for mount in [&root, &left, &right] {
assert_eq!(app.frame(mount).unwrap().revision, 0);
}
assert_eq!(app.frame(&left).unwrap().state["count"], 10);
close_all(&mut app, &root);
}
#[test]
fn child_parent_feedback_remains_cancellable_without_partial_publication() {
let timer = timer();
let (input, packages, _) = fixture(
MOUNT,
CHILD_EVENT,
"return frame({...state,label:props.label,revision:state.revision+1},[{name:'changed',value:1}]);",
"return frame({...state,version:state.version+1,revision:state.revision+1});",
"{changed:'notice'}",
);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
let left = app.child_mount(&root, "left").unwrap().clone();
maka_ui_runtime::Application::dispatch(&mut app, &left, 0, "inc", Value::Null).unwrap();
assert!(
timer
.block_on(async {
tokio::time::timeout(std::time::Duration::from_millis(100), app.wait_idle()).await
})
.is_err()
);
app.retire(&root).unwrap();
assert!(timer.block_on(app.wait_idle()).is_err());
assert_eq!(app.frame(&root).unwrap().revision, 0);
assert_eq!(app.frame(&left).unwrap().revision, 0);
close_all(&mut app, &root);
}
#[test]
fn mount_emission_can_remove_a_whole_private_subtree_without_renderer_ack() {
let timer = timer();
let (mut input, mut packages, contract) = fixture(
"return frame({count:props.initial,label:props.label,revision:0},props.initial===20?[{name:'changed',value:1}]:[]);",
CHILD_EVENT,
support::CHILD_UPDATE,
"return frame({...state,right:false,revision:state.revision+1});",
"{changed:'remove'}",
);
let old = packages.pop().unwrap();
let leaf = support::package(
"dynamic.leaf",
old.manifest()
.application
.as_ref()
.unwrap()
.declaration()
.clone(),
Some(
std::str::from_utf8(old.file("index.mjs").unwrap())
.unwrap()
.to_owned(),
),
);
let mut declaration = old
.manifest()
.application
.as_ref()
.unwrap()
.declaration()
.clone();
declaration
.entries
.get_mut(&EntryTarget::Tui)
.unwrap()
.imports
.insert(
"leaf".into(),
Import {
contract,
cardinality: Cardinality::One,
},
);
let source=std::str::from_utf8(old.file("index.mjs").unwrap()).unwrap().replace("children:[","children:[{kind:'component',key:'nested',binding:'leaf',member:0,on:{},props:{label:'Leaf',initial:1}},");
let counter = support::package("dynamic.counter", declaration, Some(source));
input.instances.get_mut("counter").unwrap().package = counter.application_ref().into();
input.instances.get_mut("counter").unwrap().bindings.insert(
"leaf".into(),
vec![Target::Instance {
path: vec!["leaf".into()],
export: "Counter".into(),
}],
);
input
.instances
.insert("leaf".into(), support::node(&leaf, 4));
packages.extend([counter, leaf]);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
assert_eq!(app.frame(&root).unwrap().revision, 1);
assert!(app.child_mount(&root, "right").is_err());
let visible = app.mounted_subtree(&root).unwrap().len();
assert_eq!(visible, 3);
assert!(app.retiring_mounts().is_empty());
app.retire(&root).unwrap();
assert_eq!(
app.retiring_mounts().len(),
visible,
"private orphan cannot become active or wait for a renderer"
);
for mount in app.retiring_mounts() {
app.close(&mount, Ok(())).unwrap();
}
app.shutdown().unwrap();
}
struct NativeEmitter;
impl NativeComponent<()> for NativeEmitter {
fn call(
&self,
call: ComponentCall<'_>,
_values: &maka_ui_runtime::abi::ValueBindings,
) -> maka_ui_runtime::Result<ComponentOutput<NativeView<()>>> {
let (state, events) = match call {
ComponentCall::Mount { props } => (
json!({"revision":0,"count":props["initial"],"label":props["label"]}),
Vec::new(),
),
ComponentCall::Update { state, props } => (
json!({"revision":state["revision"].as_u64().unwrap()+1,"count":state["count"],"label":props["label"]}),
Vec::new(),
),
ComponentCall::Event { state, .. } => (
json!({"revision":state["revision"].as_u64().unwrap()+1,"count":state["count"].as_u64().unwrap()+1,"label":state["label"]}),
vec![Emission {
name: "changed".into(),
value: json!(7),
}],
),
};
let output = Frame {
revision: state["revision"].as_u64().unwrap(),
state,
surface: None,
view: crate::support::scene::view(Node::Button {
key: "inc".into(),
label: "Native".into(),
})?,
}
.output(events);
Ok(output)
}
}
#[test]
fn native_emission_and_js_emission_share_the_same_contract_and_subscription() {
let timer = timer();
let (input, packages, contract) = fixture(
MOUNT,
CHILD_EVENT,
support::CHILD_UPDATE,
RECEIVE,
"{changed:'notice'}",
);
let mut app = native_application(input, packages, contract, Rc::new(NativeEmitter), &timer);
let root = app.mount_launch(json!({})).unwrap();
let right = app.child_mount(&root, "right").unwrap().clone();
app.dispatch(&right, 0, "inc", Value::Null).unwrap();
assert_eq!(
app.frame(&root).unwrap().state["seen"],
json!([["right", "changed", "notice", 7]])
);
assert_eq!(app.frame(&right).unwrap().state["count"], 21);
close_all(&mut app, &root);
}
fn native_application(
mut input: Input,
mut packages: Vec<Package>,
contract: Contract,
component: Rc<dyn NativeComponent<()>>,
timer: &tokio::runtime::Runtime,
) -> Application {
let old = packages.pop().unwrap();
let mut declaration = old
.manifest()
.application
.as_ref()
.unwrap()
.declaration()
.clone();
let entry = declaration.entries.get_mut(&EntryTarget::Tui).unwrap();
entry.scene = None;
entry.implementation = Implementation::Native {
implementation: "test.events".into(),
};
entry.exports.get_mut("Counter").unwrap().contract = contract.clone();
let native = support::package("native.counter", declaration, None);
input.instances.get_mut("counter").unwrap().package = native.application_ref().into();
let old = packages.pop().unwrap();
let mut declaration = old
.manifest()
.application
.as_ref()
.unwrap()
.declaration()
.clone();
declaration
.entries
.get_mut(&EntryTarget::Tui)
.unwrap()
.imports
.get_mut("counter")
.unwrap()
.contract = contract.clone();
let root = support::package(
"dynamic.app",
declaration,
Some(
std::str::from_utf8(old.file("index.mjs").unwrap())
.unwrap()
.to_owned(),
),
);
input.instances.get_mut("app").unwrap().package = root.application_ref().into();
packages.extend([root, native]);
let mut registry = crate::support::scene::registry(PresentationTarget::Tui);
registry
.register("test.events", move |_, _values| {
Ok(BTreeMap::from([(
"Counter".into(),
NativeExport {
contract: contract.clone(),
value: NativeCapability::Component(component.clone()),
},
)]))
})
.unwrap();
Application::new(
&input,
packages,
timer.handle().clone(),
maka_ui_js::execution(Default::default()),
registry,
Default::default(),
)
.unwrap()
}
#[test]
fn raw_input_commits_the_complete_causal_batch() {
let timer = timer();
let (input, packages, _) = fixture(
MOUNT,
"const out=frame({...state,count:state.count+1,revision:state.revision+1},[{name:'changed',value:3}]);return out;",
support::CHILD_UPDATE,
RECEIVE,
"{changed:'notice'}",
);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
let child = app.child_mount(&root, "left").unwrap().clone();
app.dispatch(&child, 0, "inc", Value::Null).unwrap();
assert_eq!(app.frame(&root).unwrap().state["total"], 3);
assert_eq!(app.frame(&child).unwrap().state["count"], 11);
assert!(app.dispatch(&child, 0, "inc", Value::Null).is_err());
close_all(&mut app, &root);
}
#[test]
fn failed_causal_transaction_never_publishes_child_or_parent() {
let timer = timer();
for receive in [
"throw new Error('parent rejects batch');",
"const out=frame({...state,revision:state.revision+2});return out;",
] {
let (input, packages, _) = fixture(
MOUNT,
"const out=frame({...state,count:99,revision:state.revision+1},[{name:'changed',value:3}]);return out;",
support::CHILD_UPDATE,
receive,
"{changed:'notice'}",
);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
let child = app.child_mount(&root, "left").unwrap().clone();
assert!(app.dispatch(&child, 0, "inc", Value::Null).is_err());
assert_eq!(app.frame(&root).unwrap().revision, 0);
assert_eq!(app.frame(&child).unwrap().revision, 0);
assert_eq!(app.frame(&child).unwrap().state["count"], 10);
close_all(&mut app, &root);
}
}
#[test]
fn parent_causal_notifications_preserve_component_origin() {
let timer = timer();
let (input, packages, _) = fixture(
MOUNT,
"return frame({...state,revision:state.revision+1},[{name:'changed',value:3}]);",
support::CHILD_UPDATE,
"if(event.kind!=='component')throw new Error('origin was rewritten');return frame({...state,total:event.event,revision:state.revision+1});",
"{changed:'notice'}",
);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
let child = app.child_mount(&root, "left").unwrap().clone();
app.dispatch(&child, 0, "inc", Value::Null).unwrap();
assert_eq!(app.frame(&root).unwrap().state["total"], 3);
close_all(&mut app, &root);
}
#[test]
fn obsolete_input_policy_is_rejected_on_props_update_notifications() {
let timer = timer();
let (input, packages, _) = fixture(
MOUNT,
CHILD_EVENT,
"return frame({...state,label:props.label,revision:state.revision+1},[{name:'changed',value:42}]);",
"if(event.event===42){const out=frame({...state,revision:state.revision+1});out.decision={preventDefault:true};return out;}return frame({...state,version:state.version+1,revision:state.revision+1});",
"{changed:'notice'}",
);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
let child = app.child_mount(&root, "left").unwrap().clone();
let error = app.dispatch(&child, 0, "inc", Value::Null).unwrap_err();
assert!(error.to_string().contains("unknown field"));
assert_eq!(app.frame(&root).unwrap().revision, 0);
assert_eq!(app.frame(&child).unwrap().revision, 0);
assert_eq!(app.frame(&child).unwrap().state["count"], 10);
close_all(&mut app, &root);
}
#[test]
fn lifecycle_outputs_cannot_decide_input_and_payload_cannot_forge_its_origin() {
let timer = timer();
let (input, packages, _) = fixture(
"const out=frame({count:props.initial,label:props.label,revision:0});out.decision={};return out;",
CHILD_EVENT,
support::CHILD_UPDATE,
RECEIVE,
"{}",
);
let mut app = application(&input, packages, &timer);
assert!(app.mount_launch(json!({})).is_err());
app.shutdown().unwrap();
let mut app = super::app(
"if(event.kind!=='raw')throw new Error('wrong origin');const out=frame({count:state.count+1,revision:state.revision+1});return out;",
&timer,
);
let root = app.mount_launch(json!({})).unwrap();
app.dispatch(&root, 0, "inc", json!({"kind":"component","decision":{}}))
.unwrap();
assert_eq!(app.frame(&root).unwrap().state["count"], 1);
close_all(&mut app, &root);
}
#[test]
fn presentation_change_updates_a_wide_retained_tree_atomically() {
let timer = timer();
let (mut input, mut packages) =
support::composed_fixture(support::CHILD_UPDATE, support::PARENT_EVENT);
let original = packages.pop().unwrap();
let source = std::str::from_utf8(original.file("index.mjs").unwrap()).unwrap();
let mut declaration = original
.manifest()
.application
.as_ref()
.unwrap()
.declaration()
.clone();
let leaf = support::package("dynamic.leaf", declaration.clone(), Some(source.into()));
let contract = declaration.entries[&EntryTarget::Tui].exports["Counter"]
.contract
.clone();
declaration
.entries
.get_mut(&EntryTarget::Tui)
.unwrap()
.imports
.insert(
"leaf".into(),
Import {
contract,
cardinality: Cardinality::One,
},
);
let counter = support::package(
"dynamic.counter",
declaration,
Some(format!(
r#"{source}
const originalFrame=frame;
frame=state=>{{
const out=originalFrame(state);
out.frame.view.children.push(...Array.from({{length:260}},(_,i)=>({{
kind:'component',key:'leaf-'+i,binding:'leaf',member:0,on:{{}},props:{{label:state.label,initial:i}}
}})));
return out;
}};
"#
)),
);
input.instances.get_mut("counter").unwrap().package = counter.application_ref().into();
input.instances.get_mut("counter").unwrap().bindings.insert(
"leaf".into(),
vec![Target::Instance {
path: vec!["leaf".into()],
export: "Counter".into(),
}],
);
input
.instances
.insert("leaf".into(), support::node(&leaf, 4));
packages.extend([counter, leaf]);
let mut app = application(&input, packages, &timer);
let root = app.mount_launch(json!({})).unwrap();
let groups: Vec<_> = ["left", "right"]
.into_iter()
.map(|key| app.child_mount(&root, key).unwrap().clone())
.collect();
let children: Vec<_> = groups
.iter()
.flat_map(|group| {
(0..260).map(|i| {
app.child_mount(group, &format!("leaf-{i}"))
.unwrap()
.clone()
})
})
.collect();
for version in 1..=3 {
app.dispatch(&root, version - 1, "parent", Value::Null)
.unwrap();
for (i, child) in children.iter().enumerate() {
assert_eq!(
app.child_mount(&groups[i / 260], &format!("leaf-{}", i % 260))
.unwrap(),
child
);
let frame = app.frame(child).unwrap();
assert_eq!(frame.revision, version);
assert_eq!(
frame.state["label"],
format!("{} {version}", if i < 260 { "Left" } else { "Right" })
);
assert_eq!(frame.state["count"], i % 260);
}
}
close_all(&mut app, &root);
}