use std::{future::Future, time::Duration};
use lenso_app_plan::ResolvedAppPlan;
use lenso_kernel::{
ExecutionAdapterCatalog, Kernel, NativeApp, PluginDependencies, RuntimeFailure, ShutdownOutcome,
};
use lenso_native_adapter::{NativePluginFactory, NativePluginRegistry};
use lenso_plugin_authoring::CapabilityClient;
mod clock;
mod durable;
mod entropy;
mod faults;
mod receipt;
mod receipt_diagnostics;
mod simulator;
pub use clock::TestWallClock;
pub use durable::{CommitKnowledge, DurableFailure, DurableFailureCause, DurableFaultFacade};
pub use entropy::TestEntropy;
pub use faults::{FaultInjector, FaultPointError, ScenarioBoundary, SimulatorFault};
pub use receipt::{ScenarioReceipt, ScenarioReceiptEvent, ScenarioTerminal, ScenarioTransition};
pub use receipt_diagnostics::{ReceiptDifference, first_receipt_difference};
pub use simulator::{SimulatorGate, SimulatorResource, TestSimulator};
#[derive(Debug)]
pub struct TestAppBuilder {
plan: ResolvedAppPlan,
assembly: TestAssembly,
simulator: TestSimulator,
}
#[derive(Debug)]
enum TestAssembly {
Registry(NativePluginRegistry, bool),
Catalog(ExecutionAdapterCatalog),
Conflicting,
}
impl TestAppBuilder {
pub fn new(plan: ResolvedAppPlan) -> Self {
Self {
plan,
assembly: TestAssembly::Registry(NativePluginRegistry::new(), false),
simulator: TestSimulator::new(),
}
}
#[must_use]
pub fn with_factory(self, factory: impl NativePluginFactory) -> Self {
self.map_registry(|registry| registry.with_factory(factory))
}
#[must_use]
pub fn with_registry(self, registry: NativePluginRegistry) -> Self {
self.map_registry(|_| registry)
}
#[must_use]
pub fn with_linked_factories(self) -> Self {
self.map_registry(NativePluginRegistry::with_linked_factories)
}
fn map_registry(
mut self,
update: impl FnOnce(NativePluginRegistry) -> NativePluginRegistry,
) -> Self {
self.assembly = match self.assembly {
TestAssembly::Registry(registry, _) => TestAssembly::Registry(update(registry), true),
_ => TestAssembly::Conflicting,
};
self
}
#[must_use]
pub fn with_catalog(mut self, catalog: ExecutionAdapterCatalog) -> Self {
self.assembly = match self.assembly {
TestAssembly::Registry(_, false) => TestAssembly::Catalog(catalog),
_ => TestAssembly::Conflicting,
};
self
}
#[must_use]
pub fn with_simulator(mut self, simulator: TestSimulator) -> Self {
self.simulator = simulator;
self
}
pub fn start(self) -> Result<TestApp, RuntimeFailure> {
let driver = self.simulator.driver();
let catalog =
match self.assembly {
TestAssembly::Registry(registry, _) => ExecutionAdapterCatalog::single(registry),
TestAssembly::Catalog(catalog) => catalog,
TestAssembly::Conflicting => return Err(RuntimeFailure::InvalidResolvedPlan {
detail:
"test App catalog and registry/factory inputs cannot be mixed or repeated"
.into(),
}),
};
let app = driver.run(Kernel::start(self.plan, driver.clone(), catalog))?;
Ok(TestApp {
simulator: self.simulator,
app,
})
}
}
#[derive(Debug)]
pub struct TestApp {
simulator: TestSimulator,
app: NativeApp,
}
impl TestApp {
pub fn builder(plan: ResolvedAppPlan) -> TestAppBuilder {
TestAppBuilder::new(plan)
}
pub fn app(&self) -> &NativeApp {
&self.app
}
pub fn simulator(&self) -> &TestSimulator {
&self.simulator
}
pub fn client<C>(&self, consumer_instance: &str) -> Result<C, RuntimeFailure>
where
C: CapabilityClient<Dependencies = PluginDependencies, Error = RuntimeFailure>,
{
let dependencies = self.app.dependencies(consumer_instance)?;
C::from_dependencies(&dependencies)
}
pub fn run<F: Future>(&self, future: F) -> F::Output {
self.simulator.run(future)
}
pub fn advance(&self, duration: Duration) {
self.simulator.advance(duration);
}
pub fn shutdown(&self, timeout: Duration) -> ShutdownOutcome {
self.simulator.run(self.app.shutdown(timeout))
}
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, rc::Rc};
use futures::{FutureExt, future::Either};
use lenso_app_plan::PluginInstancePlan;
use lenso_kernel::{ActivateContext, PluginFuture, PluginLifecycle};
use lenso_native_adapter::{NativePluginFactoryContext, NativePluginInstance};
use super::*;
#[derive(Debug)]
struct GateLifecycle {
gate: SimulatorGate,
completed: Rc<Cell<bool>>,
cancelled: Rc<Cell<bool>>,
}
impl PluginLifecycle for GateLifecycle {
fn activate(&self, context: ActivateContext) -> PluginFuture {
let gate = self.gate.clone();
let completed = self.completed.clone();
let cancelled = self.cancelled.clone();
let cancellation = context.cancellation();
let spawned = context.tasks().spawn_local(Box::pin(async move {
let gate_wait = gate.wait().fuse();
let cancellation_wait = cancellation.cancelled().fuse();
futures::pin_mut!(gate_wait, cancellation_wait);
match futures::future::select(gate_wait, cancellation_wait).await {
Either::Left(((), _)) => completed.set(true),
Either::Right(((), _)) => cancelled.set(true),
}
}));
Box::pin(async move {
spawned
.map(|_| ())
.map_err(|error| RuntimeFailure::PluginFailure {
detail: format!("could not spawn simulator fixture task: {error:?}"),
})
})
}
}
#[derive(Debug)]
struct GateFactory {
gate: SimulatorGate,
completed: Rc<Cell<bool>>,
cancelled: Rc<Cell<bool>>,
}
impl NativePluginFactory for GateFactory {
fn package_id(&self) -> &'static str {
"test.simulator-gate"
}
fn instantiate(
&self,
_context: NativePluginFactoryContext<'_>,
) -> Result<NativePluginInstance, RuntimeFailure> {
Ok(NativePluginInstance::with_lifecycle(
Vec::new(),
GateLifecycle {
gate: self.gate.clone(),
completed: self.completed.clone(),
cancelled: self.cancelled.clone(),
},
))
}
}
fn gate_plan() -> ResolvedAppPlan {
ResolvedAppPlan::new(
vec![PluginInstancePlan::new("gate", "test.simulator-gate")],
vec![],
)
}
#[test]
fn empty_test_app_starts_ready_and_shuts_down_cleanly() {
let app = TestApp::builder(ResolvedAppPlan::empty()).start().unwrap();
assert!(app.app().is_ready());
assert_eq!(app.shutdown(Duration::from_secs(1)), ShutdownOutcome::Clean);
}
#[test]
fn plan_selected_plugin_without_a_factory_fails_closed() {
let plan = ResolvedAppPlan::new(
vec![PluginInstancePlan::new("missing", "test.missing")],
vec![],
);
let error = TestApp::builder(plan).start().unwrap_err();
assert!(
matches!(error, RuntimeFailure::MissingPluginFactory { instance, .. } if instance == "missing")
);
}
#[test]
fn builder_keeps_the_supplied_simulator_with_the_real_native_app() {
let simulator = TestSimulator::new();
let app = TestApp::builder(ResolvedAppPlan::empty())
.with_simulator(simulator.clone())
.start()
.unwrap();
simulator.advance(Duration::from_millis(5));
assert_eq!(app.simulator().now(), Duration::from_millis(5));
assert_eq!(app.shutdown(Duration::from_secs(1)), ShutdownOutcome::Clean);
}
#[test]
fn gate_controls_managed_native_plugin_work_after_app_start() {
let simulator = TestSimulator::new();
let gate = simulator.gate("fixture.after-activate");
let completed = Rc::new(Cell::new(false));
let cancelled = Rc::new(Cell::new(false));
let app = TestApp::builder(gate_plan())
.with_simulator(simulator.clone())
.with_factory(GateFactory {
gate: gate.clone(),
completed: completed.clone(),
cancelled: cancelled.clone(),
})
.start()
.unwrap();
simulator.pump();
assert_eq!(gate.reached_count(), 1);
assert!(!completed.get());
assert!(gate.release());
simulator.pump();
assert!(completed.get());
assert!(!cancelled.get());
assert_eq!(app.shutdown(Duration::from_secs(1)), ShutdownOutcome::Clean);
}
#[test]
fn shutdown_cancels_unreleased_managed_native_plugin_work() {
let simulator = TestSimulator::new();
let gate = simulator.gate("fixture.awaiting-shutdown");
let completed = Rc::new(Cell::new(false));
let cancelled = Rc::new(Cell::new(false));
let app = TestApp::builder(gate_plan())
.with_simulator(simulator.clone())
.with_factory(GateFactory {
gate: gate.clone(),
completed: completed.clone(),
cancelled: cancelled.clone(),
})
.start()
.unwrap();
simulator.pump();
assert_eq!(gate.reached_count(), 1);
assert!(!completed.get());
assert_eq!(app.shutdown(Duration::from_secs(1)), ShutdownOutcome::Clean);
assert!(!completed.get());
assert!(cancelled.get());
}
}