# AOI Unit Tests

Source: https://rungs.dev/docs/studio/aoi-unit-tests

> Validate Add-On Instruction logic in rungs.dev with YAML-based unit tests that drive inputs, advance scans, and assert outputs across single and multi-step scenarios.

Rungs uses YAML-based test cases to validate your AOI logic. Tests define inputs, advance the simulation, and check outputs — verifying behavior across single scans, multi-step sequences, and timed progressions.

Every test case begins with [prescan](https://rungs.dev/docs/scan-cycle/the-scan-cycle#before-the-first-scan), a preparation pass that puts stateful instructions into their startup state before the first test scan.

## YAML Format [#yaml-format]

Each test file is a YAML array of test cases. Every test case has a `name` and one or more `steps`.

```yaml
- name: motor starts on command
  steps:
    - in:
        StartButton: 1
      advance:
        scans: 1
      expect:
        MotorRun: 1
```

### Step Fields [#step-fields]

| Field     | Required | Description                                  |
| --------- | -------- | -------------------------------------------- |
| `in`      | No       | Sets input tag values before execution       |
| `advance` | Yes      | How far to advance the simulation            |
| `hold`    | No\*     | Output tag values to verify after every scan |
| `expect`  | No\*     | Output tag values to verify after execution  |

\* Every step must check something: include `hold`, `expect`, or both. A phase where an
output should stay steady usually needs only `hold` — it already covers the final scan.

### `in` — Set Inputs [#in--set-inputs]

Assigns values to **input** tags before the step executes. Only tags with `usage: input` are valid. Values must be numbers (`0`, `1`, `3.14`).

```yaml
in:
  StartButton: 1
  Temperature: 72.5
```

Omit `in` when testing default behavior:

```yaml
- name: green light when idle
  steps:
    - advance:
        scans: 1
      expect:
        GreenLight: 1
```

### `advance` — Progress the Simulation [#advance--progress-the-simulation]

Every step must specify how to advance. Two modes are available:

**Scan-based** — run a specific number of deterministic test scans:

```yaml
advance:
  scans: 1
```

Test scans are 100 ms apart in simulation time unless the test case sets its own pace with
`scanTime` (see below). Unlike **Start**, tests do not wait for real time, so they run
quickly and produce the same timing on every run.

**Time-based** — specify a duration in milliseconds or seconds:

```yaml
advance:
  time: 500ms
```

```yaml
advance:
  time: 3.1s
```

Time values must be exact multiples of the test scan time. At the default 100 ms, the test
runner rejects values such as `250ms` instead of silently rounding them to a different
duration.

| Duration | Scans (at 100 ms) |
| -------- | ----------------- |
| `100ms`  | 1                 |
| `500ms`  | 5                 |
| `1s`     | 10                |
| `3.1s`   | 31                |
| `1m`     | 600               |
| `1h`     | 36000             |

### `scanTime` — Set the Test Scan Pace [#scantime--set-the-test-scan-pace]

A test case can declare how far apart its scans are, as a top-level field next to `name`:

```yaml
- name: catches a fast pulse
  scanTime: 20ms
  steps:
    - in:
        StartButton: 1
      advance:
        time: 200ms
      expect:
        MotorRunning: 1
```

Why change it? A real controller scans much faster than 100 ms. A finer `scanTime` checks
timing behavior more precisely — a transition that happens 100 ms late is invisible at the
default pace but plainly wrong at `20ms`. Allowed values are whole milliseconds from `10ms`
to `1000ms`; when omitted, the default is 100 ms. Remember that `scans`-based advances mean
scans, so their duration changes with `scanTime`, while `time`-based advances always mean
the same elapsed time.

### `expect` — Verify Outputs [#expect--verify-outputs]

Checks **output** tag values after the step finishes. Only tags with `usage: output` are valid. Structured types (TIMER, COUNTER) and array tags cannot be checked directly.

```yaml
expect:
  MotorRun: 1
  FaultActive: 0
```

### `hold` — Verify Outputs Stay Steady [#hold--verify-outputs-stay-steady]

`expect` only checks outputs after the **last** scan of a step. If a step advances 50 scans and an output briefly drops in the middle, `expect` never sees it. `hold` closes that gap: it checks the listed outputs after **every** scan of the step, so a value that flickers mid-step fails the test.

```yaml
- name: red phase holds for its full duration
  steps:
    - in:
        Enable: 1
      advance:
        scans: 1
      expect:
        RedLight: 1
    - advance:
        time: 4900ms
      hold:
        RedLight: 1
        GreenLight: 0
      expect:
        RedLight: 1
```

The same rules as `expect` apply: output tags only, numeric values, no structured or array tags. A failure reports the exact scan where the value first went wrong, e.g. `hold failed at step 2, scan 7/49 (t=4800ms): RedLight must remain 1 throughout this step; got 0`.

Use `hold` for "stays at this value the whole time" and `expect` for "ends at this value". A step needs at least one of them; a steady phase is usually `hold`-only, because `hold` already checks the final scan.

## Test Patterns [#test-patterns]

### Single-Step Tests [#single-step-tests]

Test combinatorial logic that resolves in one scan:

```yaml
- name: both inputs required for series
  steps:
    - in:
        InputA: 1
        InputB: 1
      advance:
        scans: 1
      expect:
        SeriesResult: 1
```

### Multi-Step Tests [#multi-step-tests]

Verify stateful behavior by chaining steps. State persists across all steps in a test case — local variables, latches, and accumulators carry forward.

**Latch persistence:**

```yaml
- name: latch persists after command released
  steps:
    - in:
        LatchCmd: 1
      advance:
        scans: 1
      expect:
        LatchedOut: 1
    - in:
        LatchCmd: 0
      advance:
        scans: 1
      expect:
        LatchedOut: 1
```

**Fault seal-in and reset:**

```yaml
- name: clears fault on reset
  steps:
    - in:
        OverloadTrip: 1
      advance:
        scans: 1
      expect:
        FaultActive: 1
    - in:
        OverloadTrip: 0
        ResetFault: 1
      advance:
        scans: 1
      expect:
        FaultActive: 0
```

### Input Persistence [#input-persistence]

Input values set via `in` persist into subsequent steps until explicitly changed. If step 1 sets `StartButton: 1`, step 2 will still see `StartButton: 1` unless overridden.

```yaml
- name: motor stays running after start released
  steps:
    - in:
        StartButton: 1
      advance:
        scans: 1
      expect:
        MotorRun: 1
    - in:
        StartButton: 0
      advance:
        scans: 1
      expect:
        MotorRun: 1
```

Step 2 explicitly sets `StartButton: 0` — without this, it would remain `1` from step 1.

### Timer Tests [#timer-tests]

Use `advance: time:` to progress timers through their phases:

```yaml
- name: timer completes after preset elapses
  steps:
    - in:
        Enable: 1
      advance:
        time: 3.1s
      expect:
        TimerDone: 1
        TimerTiming: 0
```

The first active scan starts this `TON` at `.ACC = 0`; it does not add 100 ms immediately. The example uses `3.1s` so a 3-second timer receives its start scan followed by 30 elapsed 100 ms intervals. Use time-based advances for timer tests and reserve `scans: 1` for scan-order, edge, and reset checks.

**Timer reset on disable (TON behavior):**

```yaml
- name: timer resets when enable drops
  steps:
    - in:
        Enable: 1
      advance:
        time: 1s
      expect:
        TimerTiming: 1
    - in:
        Enable: 0
      advance:
        scans: 1
      expect:
        TimerTiming: 0
        TimerAcc: 0
```

### Multi-Scan Accumulation [#multi-scan-accumulation]

Advance multiple scans in a single step to test accumulating behavior:

```yaml
- name: triggers high alarm above 80
  steps:
    - in:
        FillCmd: 1
      advance:
        scans: 24
      expect:
        HighAlarm: 1
```

### Edge-Triggered Counters [#edge-triggered-counters]

LD counters (CTU/CTD) count on rising edges. Toggle inputs between steps to generate multiple counts:

```yaml
- name: multiple pulses increment counter
  steps:
    - in:
        CountUpPulse: 1
      advance:
        scans: 1
      expect:
        CountValue: 1
    - in:
        CountUpPulse: 0
      advance:
        scans: 1
      expect:
        CountValue: 1
    - in:
        CountUpPulse: 1
      advance:
        scans: 1
      expect:
        CountValue: 2
```

### Phased Sequences [#phased-sequences]

Combine time and scan advances to test state machines with multiple phases:

```yaml
- name: transitions to red after yellow
  steps:
    - in:
        WalkRequest: 1
      advance:
        scans: 1
      expect:
        YellowLight: 1
    - in:
        WalkRequest: 0
      advance:
        time: 1s
      expect:
        RedLight: 1
        WalkSign: 1
```

### Holding a Phase Steady [#holding-a-phase-steady]

A phase that lasts many scans can hide a glitch: `expect` only samples the last scan, so an output that briefly drops mid-phase still passes. Give the transition its own short step with `expect`, then `hold` the rest of the phase. An input-driven change (like `Enable` going high) lands on that same scan, so one scan is enough. A timer-driven change needs a 2–3 scan window instead — correct programs can update the output on the scan the timer finishes or a scan or two later, depending on the order of their statements or rungs:

```yaml
- name: red phase holds for its full duration
  steps:
    - in:
        Enable: 1
      advance:
        scans: 1
      expect:
        RedLight: 1
    - advance:
        time: 4900ms
      hold:
        RedLight: 1
        GreenLight: 0
        AmberLight: 0
      expect:
        RedLight: 1
```

Start the `hold` window after the transition step, and end it a scan before the next timer boundary. Scans inside the transition window can legally read either value, so a `hold` that overlaps it fails on correct programs — the failure would blame the program for something the test asked wrong.

## Validation Rules [#validation-rules]

The editor validates tests in real-time and highlights errors inline.

### Structural Rules [#structural-rules]

- Top level must be a YAML array
- Each item needs `name` (string) and `steps` (array); `scanTime` is optional
- Each step needs `advance` plus at least one of `hold` or `expect`
- Only valid step keys: `in`, `advance`, `hold`, `expect`

### Tag Rules [#tag-rules]

- `in` sets what the caller supplies: tags with `usage: input` or `usage: inout`
- `expect` and `hold` check what the AOI writes back: `usage: output` or `usage: inout`
- Name one value, never a whole array or structure. A key can be a tag name or a path into it:

  | Key                 | Names                        |
  | ------------------- | ---------------------------- |
  | `Out_Count`         | a scalar tag                 |
  | `Ref_Buf[2]`        | one element of an array      |
  | `Ref_Tmr.ACC`       | one member of a structure    |
  | `Ref_Timers[1].PRE` | a member of an array element |
  | `Ref_Word.3`        | bit 3 of a `DINT`            |

- The index must be a fixed number. `Ref_Buf[Idx]` is not allowed — a test names the element it
  means.
- All values must be finite numbers (no booleans, strings, or `NaN`)

#### Bracketed keys and YAML style [#bracketed-keys-and-yaml-style]

A key with brackets is a plain key in the usual block style, but must be quoted in flow style:

```yaml
in:
  Ref_Buf[0]: 5.0 # block style — bare
```

```yaml
in: { 'Ref_Buf[0]': 5.0 } # flow style — quoted
```

#### Setting a whole array [#setting-a-whole-array]

An `in` on an array InOut can take the whole list at once, which replaces the argument the
caller supplies:

```yaml
in:
  Ref_Buf: [1, 2, 3, 4]
```

Logix lets a caller pass an array the **same size or larger** than the declaration, so a longer
list is allowed — `SIZE` then reports the longer length. A shorter one is rejected.

### Time Format [#time-format]

Time strings are a number followed by a unit — `ms`, `s`, `m` (minutes), or `h` (hours):

```
✓  100ms  2.5s  0.5s  1500ms  1m  1h
✗  2 seconds  100  1d
```

## Editor Features [#editor-features]

### Autocomplete [#autocomplete]

Press Ctrl+Space or type `:` to trigger context-aware suggestions:

- **Top level**: test case snippets
- **Inside a step**: `in`, `advance`, `hold`, `expect` keys
- **Inside `in`**: Input and InOut tags, with array elements and structure members expanded
- **Inside `expect` or `hold`**: Output and InOut tags, expanded the same way
- **Inside `advance`**: `scans` or `time`

### Snippets [#snippets]

Full test case and step templates are available as snippets with tab stops for quick authoring.

## Execution [#execution]

Tests run via the **Run Tests** button in the toolbar. Each test case:

1. Initializes tag state from default values
2. For each step:
   - Applies `in` values to the current state
   - Executes the AOI logic routine for the specified number of scans, checking `hold` values after each scan
   - Compares output values against `expect`
3. Reports pass/fail with duration and failure details

A safety limit of 10,000 total scan cycles prevents infinite loops.

### Results [#results]

Running tests clears the previous logs and opens the status panel on **All**. Test results
also appear under **Tests**; test-file validation errors appear under **Errors**.

Each line shows a symbol and clock time. Each completed test case also shows how long the
runner took to execute it. This wall-clock duration is separate from simulated test time,
which appears as `t=...` in time-based failure details. The clock times and durations below
are examples.

**All tests passing:**

```text
◆ 12:34:56 Running tests for MotorControl_ST...
✓ 12:34:56 latches run on start (3ms)
✓ 12:34:56 motor stays running after start released (1ms)
✓ 12:34:56 stop has priority over start (1ms)
✓ 12:34:56 ── Summary ── 3 passed, 0 failed (3 total)
```

**With failures:**

```text
◆ 12:34:56 Running tests for Flasher...
✕ 12:34:56 keeps the output on for one second (4ms)
  hold failed at step 3, scan 1/5 (t=1100ms): Out_Flash must remain 0 throughout this step; got 1 (previous scan at t=1000ms: Out_Flash=1)
  expect failed at step 4 (after scan t=2300ms): Out_Seconds should be 1 at the end of this step; got 0 (inputs: In_Run=1; previous scan at t=2200ms: Out_Seconds=0)
✕ 12:34:56 ── Summary ── 0 passed, 1 failed (1 total)
```

**With validation errors:**

```text
◆ 12:34:56 Running tests for MyAOI...
● 12:34:56 (Line 4): Missing required property "advance" — use { scans: N } or { time: "Xms" }
● 12:34:56 (Line 8): Unknown tag "BadTag" — input must reference an existing input tag
✕ 12:34:56 ── Summary ── 0 passed, 1 failed (1 total)
```

### Result Types [#result-types]

| Symbol                                                                  | Meaning                                                      |
| ----------------------------------------------------------------------- | ------------------------------------------------------------ |
| ◆ | Test run started                                             |
| ✓  | Test passed, or every test in the summary passed             |
| ✕  | Test failed, summary contains failures, or execution stopped |
| ●  | Test-file validation error                                   |

### Failure Details [#failure-details]

Each mismatch names the YAML check that failed:

- `hold` reports the first bad scan for each output, including its position within the step.
- `expect` reports the bad value after the step's final scan.

Time-based test cases include the simulated time when the scan ran. Scan-only cases omit it.
The final parentheses can show input values carried from earlier steps and the output's value
after the previous scan. These details help distinguish an output that never changed from one
that changed too early or too late.

```text
hold failed at step 2, scan 7/49 (t=4800ms): RedLight must remain 1 throughout this step; got 0 (inputs: Enable=1; previous scan at t=4700ms: RedLight=1)
```

If the same final-scan mismatch fails both `hold` and `expect`, it appears only once. If the
AOI fails to compile or a scan stops with an execution error, that error replaces the normal
mismatch details.