# RclexTesting

A [`Phoenix.LiveViewTest`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html)-style
testing library for ROS 2 applications built with [rclex](https://github.com/rclex/rclex).

Describe robot behaviour as executable scenario specifications — without
manually creating nodes, publishers, subscribers, timers, clients, or
assertion loops.

```elixir
defmodule RobotTest do
  use ExUnit.Case
  use RclexTesting

  scenario "robot starts cleaning" do
    mock_service "/safety/check", SafetyCheck,
      fn _req -> %SafetyCheck.Response{allowed: true} end

    watch "/robot/state", RobotState
    publish "/mission/start", %MissionStart{id: 42}

    expect "/robot/state",
      matches(%{mode: :cleaning, speed: gt(0)})
  end
end
```

## Features

| Area | Functions |
|------|-----------|
| **Topics** | `publish/2`, `watch/2`, `expect/2,3`, `refute_message/2,3,4`, `expect_count/2,3`, `expect_sequence/2,3`, `messages/1`, `eventually/1,2` |
| **Services** | `mock_service/3`, `call_service/3,4`, `expect_service_call/2,3`, `refute_service_call/2,3,4` |
| **Actions** | `mock_action/3`, `send_goal/3,4`, `expect_goal/2,3`, `refute_goal/2,3,4`, `expect_feedback/2,3`, `expect_result/2,3` |
| **Matchers** | `matches/1`, `gt/1`, `gte/1`, `lt/1`, `lte/1`, `between/2`, `approx/1,2`, `one_of/1`, `none_of/1`, `contains/1`, `empty/0`, `not_empty/0`, `any/1`, `all/1`, `is_true/0`, `is_false/0`, `is_nil/0`, `not_nil/0`, `negate/1`, `satisfies/1` |
| **Multi-stream events** | `expect_sequence/1`, `expect_before/2,3`, `expect_after/2,3`, `expect_together/1,2`, `expect_workflow/1,2` |
| **Event descriptors** | `message/1,2`, `service_call/1,2`, `goal/1,2`, `feedback/1,2`, `result/1,2` |
| **Graph assertions** | `expect_node/1,2`, `refute_node/1,2`, `expect_topic/1`, `refute_topic/1`, `expect_publisher/1`, `expect_publisher_count/2`, `expect_publisher_node/2`, `expect_subscriber/1`, `expect_subscriber_count/2`, `expect_subscriber_node/2`, `expect_service/1`, `refute_service/1`, `expect_service_provider/2,3`, `expect_action/1`, `refute_action/1`, `expect_action_provider/2,3`, `expect_node_publishes/2,3`, `expect_node_subscribes/2,3`, `expect_node_services/2,3`, `expect_node_actions/2,3`, `eventually_node/1,2,3`, `eventually_topic/1,2`, `eventually_service/1,2`, `eventually_action/1,2`, `scenario_node_name/0` |
| **Simulated time** | `set_time/1`, `advance_time/1`, `sim_time/0` |

## Installation

Add `rclex_testing` to your test dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:rclex, "~> 0.12"},
    {:rclex_testing, path: "path/to/rclex_testing", only: :test}
  ]
end
```

Add the ROS 2 message/service/action types you need to `config/config.exs`:

```elixir
import Config

config :rclex,
  ros2_message_types: [
    "std_msgs/msg/String",
    "geometry_msgs/msg/Twist"
    # add your types here
  ],
  ros2_service_types: [
    "std_srvs/srv/SetBool"
  ],
  ros2_action_types: []
```

Then run:

```sh
mix rclex.gen.msgs
mix rclex.gen.srvs
mix rclex.gen.action
```

## Usage

Add `use RclexTesting` to any `ExUnit.Case` module. Each `scenario` block
automatically starts a unique ROS node, cleans up all publishers,
subscriptions, services, and action servers when the test exits.

```elixir
defmodule MyApp.RobotTest do
  use ExUnit.Case, async: false
  use RclexTesting

  alias Rclex.Pkgs.StdMsgs

  scenario "publishes and receives a message" do
    watch "/chatter", StdMsgs.Msg.String
    publish "/chatter", %StdMsgs.Msg.String{data: "hello"}
    msg = expect("/chatter", &(&1.data == "hello"))
    assert msg.data == "hello"
  end
end
```

## Topics

### `watch/2`

Subscribe to a topic and buffer all arriving messages.

```elixir
watch "/robot/state", RobotState
```

### `publish/2`

Publish a message. The type is inferred from the struct. A publisher is
started lazily and reused within the scenario.

```elixir
publish "/cmd_vel", %Twist{linear: %Vector3{x: 1.0}}
```

### `expect/2,3`

Assert that a message matching the predicate arrives. Returns the matched
message. Cursor-based: consecutive `expect` calls on the same topic advance
past already-matched messages.

```elixir
expect "/robot/state", &(&1.mode == :running)
expect "/robot/state", RobotState, &(&1.mode == :running)
expect "/robot/state", &(&1.mode == :running), timeout: 10_000
```

### `expect_sequence/2,3`

Assert a sequence of matchers in order on the same topic.

```elixir
expect_sequence "/robot/state", [
  matches(%{mode: :idle}),
  matches(%{mode: :starting}),
  matches(%{mode: :running})
]
```

### `refute_message/1,2,3,4`

Assert that no (matching) message arrives within a timeout window.

```elixir
refute_message "/fault_topic", timeout: 500
refute_message "/fault_topic", FaultMsg, &(&1.severity == :critical)
```

### `expect_count/2,3`

Assert that exactly N messages arrive.

```elixir
msgs = expect_count "/sensor/data", 5
```

### `messages/1`

Return all buffered messages for a topic in arrival order.

```elixir
all = messages("/robot/state")
```

### `eventually/1,2`

Poll a zero-arity predicate until truthy or timeout.

```elixir
eventually(fn -> length(messages("/data")) >= 10 end, timeout: 10_000)
```

## Services

### `mock_service/3`

Register a test service on the scenario node. Requests are recorded in the
collector and forwarded to the handler function.

```elixir
mock_service "/safety/check", SafetyCheck,
  fn _req -> %SafetyCheck.Response{allowed: true} end
```

### `call_service/3,4`

Call a service synchronously and return the response.

```elixir
response = call_service("/safety/check", SafetyCheck, %SafetyCheck.Request{})
```

### `expect_service_call/2,3`

Assert that the mocked service received a (matching) request.

```elixir
expect_service_call "/safety/check", SafetyCheck
expect_service_call "/safety/check", SafetyCheck, fn req -> req.robot_id == "r1" end
```

### `refute_service_call/2,3,4`

Assert that no (matching) request arrives.

```elixir
refute_service_call "/delete_map", DeleteMap
```

## Actions

### `mock_action/3`

Register a test action server. Goals, feedback, and results are all
recorded. The handler receives `(goal, publish_feedback)` and must return
a result struct.

```elixir
mock_action "/navigate", Navigate, fn goal, publish_feedback ->
  publish_feedback.(%Navigate.Feedback{progress: 50})
  publish_feedback.(%Navigate.Feedback{progress: 100})
  %Navigate.Result{success: true}
end
```

### `send_goal/3,4`

Send a goal to an action server and return the goal UUID.

```elixir
uuid = send_goal("/navigate", Navigate, %Navigate.Goal{x: 10, y: 20})
```

### `expect_goal/2,3`, `refute_goal/2,3,4`

Assert that a (matching) goal was or was not submitted.

```elixir
goal = expect_goal "/navigate", Navigate, fn g -> g.x == 10 end
refute_goal "/navigate", Navigate
```

### `expect_feedback/2,3`, `expect_result/2,3`

Assert that feedback or a result was produced.

```elixir
expect_feedback "/navigate", Navigate, fn fb -> fb.progress == 100 end
expect_result "/navigate", Navigate, fn r -> r.success end
```

## Matchers

Matchers return predicate functions and compose with all assertion DSL.

```elixir
expect "/robot/state",
  matches(%{
    mode: :running,
    speed: gt(0),
    battery: between(20, 100),
    pose: matches(%{x: approx(10.0, delta: 0.1)})
  })
```

| Matcher | Description |
|---------|-------------|
| `matches(%{key: matcher})` | Structural match; plain values use `==` |
| `gt(n)`, `gte(n)`, `lt(n)`, `lte(n)` | Numeric comparisons |
| `between(low, high)` | Inclusive range |
| `approx(n)`, `approx(n, delta: d)` | Floating-point equality |
| `one_of(list)`, `none_of(list)` | Membership |
| `contains(item)` | Substring or list membership |
| `empty()`, `not_empty()` | Collection emptiness |
| `any(matcher)`, `all(matcher)` | List element matchers |
| `is_true()`, `is_false()` | Boolean exact match |
| `is_nil()`, `not_nil()` | Nil checks |
| `negate(matcher)` | Invert any matcher |
| `satisfies(fn val -> ... end)` | Escape hatch for custom logic |

## Simulated Time

### Test-Controlled Time (`use_sim_time: true`)

The test controls time deterministically. Use this for unit tests and CI pipelines.

```elixir
scenario "timer fires after 1 second", use_sim_time: true do
  advance_time(1_000)  # advance 1000 ms
  expect "/tick", MyPkg.Msg.Tick
end
```

The scenario:
1. Starts the node with `use_sim_time:=true` so it subscribes to `/clock`
2. Creates a `:ros_time` clock with manual override enabled
3. Publishes an initial `Clock` message at t=0
4. On each `advance_time(ms)`, updates the override and publishes the new time

Use `set_time(ms)` to jump directly to a specific time:

```elixir
scenario "multiple phases", use_sim_time: true do
  # Phase 1: around t=0
  expect "/phase", &(&1 == :startup)
  
  # Jump to t=5s for phase 2
  set_time(5_000)
  expect "/phase", &(&1 == :running)
  
  # Advance gradually during phase 3
  advance_time(2_000)
  expect "/phase", &(&1 == :complete)
end
```

### External Time (`use_sim_time: :external`)

An external simulator (Gazebo, motion capture, etc.) publishes on `/clock`.
Use this when testing against live simulators.

```elixir
scenario "robot moves in Gazebo", use_sim_time: :external do
  # Gazebo publishes on /clock; don't call advance_time
  watch "/robot/pose", GeometryMsgs.Msg.Pose
  expect "/robot/pose", &(&1.position.z < 10)
end
```

The scenario:
1. Starts the node with `use_sim_time:=true` so it subscribes to `/clock`
2. Creates a `:ros_time` clock (without override) — it syncs automatically from `/clock`
3. Publishes nothing; the external source drives all time

Call `sim_time()` to read the current clock value (driven by external source).

### Ensuring Your Application Uses Sim Time

Any ROS node—in your application under test or spawned in a scenario—must start
with the ROS arg to recognize sim time:

```elixir
Rclex.start_node("my_node", ros_args: ["--ros-args", "-p", "use_sim_time:=true"])
```

The rclex_testing scenario automatically passes this to the test node when
`use_sim_time: true` or `use_sim_time: :external` is set. For other nodes you
spawn or test against, ensure they also receive this argument or they will
ignore `/clock` and use wall time.

## Multi-Stream Event Assertions

Beyond single-stream `expect`, rclex_testing provides a set of **cross-stream workflow
assertions** that verify ordering and causality across topics, services, and actions.

### Event Descriptors

Build blocks for multi-stream assertions. Each descriptor references a stream and an
optional predicate:

```elixir
message("/robot/state")                           # any message
message("/robot/state", &(&1.mode == :running))   # with predicate
message("/robot/state", matches(%{mode: :running})) # with matcher

service_call("/safety/check")
service_call("/safety/check", fn req -> req.allowed end)

goal("/navigate")
goal("/navigate", fn g -> g.x > 0 end)

feedback("/navigate", fn fb -> fb.progress >= 50 end)
result("/navigate", fn r -> r.success end)
```

The stream (`watch/2`, `mock_service/3`, `mock_action/3`) must be set up before
the assertion runs.

---

### `expect_sequence/1`

Assert an ordered sequence of events across any mix of topics, services, and actions.
Each event must arrive **after** the previous one (based on the global event log).

```elixir
watch "/mission/start", MissionStart
watch "/robot/state", RobotState
mock_service "/safety/check", SafetyCheck, fn _ -> ... end
mock_action "/navigate", Navigate, fn _, _ -> ... end

expect_sequence [
  message("/mission/start"),
  service_call("/safety/check"),
  goal("/navigate"),
  feedback("/navigate", fn fb -> fb.progress >= 100 end),
  result("/navigate", fn r -> r.success end),
  message("/robot/state", matches(%{mode: :completed}))
]
```

Returns the list of matched payloads in order.

For single-topic ordered assertions, the shorter form in Topics DSL is still available:

```elixir
expect_sequence "/robot/state", [
  matches(%{mode: :starting}),
  matches(%{mode: :running}),
  matches(%{mode: :completed})
]
```

---

### `expect_before/2` and `expect_after/2`

Assert a causal relationship between two events.

```elixir
# Safety check must happen before robot enters running state
{req, state} = expect_before(
  service_call("/safety/check"),
  message("/robot/state", matches(%{mode: :running}))
)

# Completed must happen after running
{earlier, later} = expect_after(
  message("/robot/state", matches(%{mode: :completed})),
  message("/robot/state", matches(%{mode: :running}))
)
```

`expect_before(a, b)` returns `{a_payload, b_payload}`.
`expect_after(later, earlier)` returns `{earlier_payload, later_payload}`.

---

### `expect_together/1`

Assert all conditions are eventually satisfied in **any order**. Each event in the log
can satisfy at most one descriptor (greedy left-to-right matching).

```elixir
[state_msg, battery_msg] = expect_together [
  message("/robot/state", matches(%{mode: :running})),
  message("/battery/state", matches(%{charging: false}))
]
```

---

### `expect_workflow/1`

Readable alias for `expect_sequence/1` using `:given` / `:then` keyword steps.
Ideal for describing cause-and-effect workflows.

```elixir
expect_workflow [
  given: message("/mission/start"),
  then:  service_call("/safety/check"),
  then:  message("/robot/state", matches(%{mode: :running})),
  then:  message("/robot/state", matches(%{mode: :completed}))
]
```

---

### Failure Messages

When a sequence assertion fails, the error includes which event was expected and what
was actually observed:

```
expect_sequence: timed out waiting for event.

Expected:
  message on "/robot/state"

Events observed after seq 2:
  [seq 3] message on /robot/state: %RobotState{mode: :completed}
```

## Design

- **Scenario node** — each `scenario` block starts a uniquely-named ROS node
  owned by the test; all entities are stopped in `on_exit`.
- **Agent buffer** — subscription and action callbacks write to a per-scenario
  `Agent`; the test process retains the buffer for `messages/1` and
  structural inspection.
- **Unified event log** — every incoming message, service call, goal, feedback, and
  result is also appended (with a monotonic sequence number) to a global log. The
  multi-stream assertions (`expect_sequence`, `expect_before`, etc.) query this log
  to verify ordering across streams.
- **Message-passing wakeup** — callbacks `send` a tagged notification to the
  test process; `expect` uses `receive` with a deadline instead of polling,
  giving sub-millisecond response latency. Cross-stream assertions additionally
  receive an `:any` notification after each event.
- **Cursor semantics** — each `expect`/`expect_service_call`/`expect_goal`
  etc. advances a per-stream cursor so consecutive assertions on the same
  stream always observe distinct events.

