defmodule Runbox.Runtime.Simple.Sandbox do
@moduledoc group: :dev_tools
@moduledoc """
Sandbox is a helper for executing runs without started Altworx application.
Sandbox can be used for scenarios unit testing and also for interactive development, e.g. in
LiveBook.
This module is only for Simple scenarios.
For interactive development of scenarios, run `iex --erl "-runbox mode slave" -S mix` from command
line in your scenarios application root directory and then use Sandbox functions to execute your
runs (of course use `recompile` before run execution if you changed scenario code).
"""
alias Runbox.Message
alias Runbox.RunStartContext
alias Runbox.Runtime.OutputAction, as: RuntimeOutputAction
alias Runbox.Runtime.Sandbox
alias Runbox.Runtime.Simple.TemplateCarrier
alias Runbox.Scenario.OutputAction
alias Runbox.Scenario.Simple
alias Runbox.StateStore.Entity
@behaviour Sandbox
@default_start_from 0
@doc """
Starts a new run for `scenario_id` and processes all input messages.
Returns all output actions generated by run execution. Input messages for each runtime topic,
referenced by its logical name, are passed in the `topics` argument. Undefined topics are
considered empty.
There are few options that can be specified.
- `:start_from` - specify the start from value for the run. The value is used to filter out
messages and is passed to the scenario's `init` callback.
- `:modules` - list of modules containing the scenario (manifest and template, which can be just
one module). If not specified, modules are auto-discovered from the current Mix app. This opens
up the possibility to write ad-hoc scenarios with only Runbox as dependency, e.g. in
[Livebook](https://livebook.dev/).
"""
@impl Sandbox
@spec execute_run(Sandbox.topics(), scenario_id :: String.t(), Sandbox.opts()) ::
{:ok, [OutputAction.t()]} | {:error, any()}
def execute_run(topics, scenario_id, opts \\ []) do
start_from = Keyword.get(opts, :start_from, @default_start_from)
with {:ok, scenario} <- get_scenario(scenario_id, opts),
# Simple scenarios always have single template
[template] <- scenario.templates,
{:ok, input_topics} <- Simple.input_topics(template.module),
:ok <- maybe_execute_on_start(scenario),
{:ok, state, init_output_actions} <- init(scenario, template, start_from),
messages = prepare_messages(topics, input_topics, start_from),
{:ok, message_output_actions} <- handle_messages(messages, state) do
{:ok, process_outputs(init_output_actions ++ message_output_actions)}
end
end
defp get_scenario(id, opts) do
scenario = Keyword.get(opts, :scenario)
if scenario do
{:ok, scenario}
else
modules = Keyword.get(opts, :modules) || Sandbox.modules_from_mix_app()
Sandbox.find_scenario(id, modules)
end
end
# Init the scenario - simulate the start of the template carrier, including executing the init
# phase.
defp init(scenario, template, start_from) do
run_id = UUID.uuid1()
args = %{
run_id: run_id,
config: %{
module: template.module,
subscribe_to: [:fake_sub],
scenario_id: scenario.manifest.id,
start_from: start_from,
start_or_continue: :start
},
state_entity: Entity.new(run_id, :template, :none, start_from, nil)
}
{:producer_consumer, state, _} =
TemplateCarrier.init({args, nil, %RunStartContext{components_pids: %{fake_sub: self()}}})
receive do
:init_state -> :ok
end
init_template(state)
end
# Execute the initialize phase of the template.
defp init_template(state) do
{:noreply, outputs, state} = TemplateCarrier.handle_info(:init_state, state)
{:ok, state, outputs}
end
# Prepare messages to be handled later. This includes converting it from the sandbox format into
# just a list of messages, filtering out messages we are not interested in etc.
defp prepare_messages(messages_in_topics, input_topics, start_from) do
consumed_topics =
Map.new(input_topics, fn
{topic, type} -> {topic, type}
topic -> {topic, :input}
end)
messages_in_topics
|> Enum.filter(fn {topic, _} -> Map.has_key?(consumed_topics, topic) end)
|> Enum.flat_map(fn {topic, messages} ->
Enum.map(messages, fn %Message{} = msg -> %{msg | from: topic} end)
end)
|> Enum.filter(&(consumed_topics[&1.from] == :load || &1.timestamp >= start_from))
|> Enum.sort_by(fn %Message{timestamp: time} -> time end)
end
defp handle_messages(messages, state) do
{:noreply, outputs, _state} = TemplateCarrier.handle_events(messages, nil, state)
{:ok, outputs}
end
# Convert outputs to the final format and remove non-OA outputs.
defp process_outputs(outputs) do
Enum.flat_map(outputs, fn
%RuntimeOutputAction{} = oa ->
[
%OutputAction{
body: oa.body,
timestamp: oa.timestamp
}
]
# other non-OA stuff the TemplateCarrier might produce, like ticks
_other ->
[]
end)
end
defp maybe_execute_on_start(scenario) do
case scenario.opts[:on_start] do
{m, f, a} -> apply(m, f, a)
nil -> :ok
end
end
end