---
wave: 2
depends_on:
  - 01-PLAN-01
files_modified:
  - crates/opendeviationbar-streaming/src/live_engine.rs
requirements:
  - JUNC-01
  - JUNC-02
  - JUNC-03
  - JUNC-04
autonomous: true
---

# Plan 02: Junction Fill Unit Tests

## Goal

Add unit tests to `live_engine.rs` `#[cfg(test)] mod tests` that validate all 4 success criteria for the junction fix: zero Stathera gaps at REST-to-WS transition, Puck suppression, forming bar preservation, and monotonicity guard.

## Tasks

<task id="02-01">
<name>Add test: junction fill bridges REST→WS gap with zero Stathera gaps</name>
<read_first>
- crates/opendeviationbar-streaming/src/live_engine.rs (lines 1453-1670, existing test module)
- crates/opendeviationbar-streaming/src/gap/detector.rs (full file — seed, check, update_last_tid API)
</read_first>
<action>
Add a new `#[test]` (synchronous, not `#[tokio::test]`) to the `mod tests` block in `crates/opendeviationbar-streaming/src/live_engine.rs`. The test name: `test_junction_fill_zero_stathera_gaps`.

The test simulates the junction scenario:

1. Create a `TradeIdGapDetector` with an `mpsc::channel::<GapEvent>(16)` sender.
2. Create a processor via `OpenDeviationBarProcessor::new(250).unwrap()`.
3. Process 5 REST trades (IDs 100-104, price 50000.0, timestamps 1700000000000 + i\*1000 ms) through the processor to simulate `fill_from_rest` state. Use the existing `make_trade()` helper.
4. Verify `processor.last_agg_trade_id() == Some(104)`.
5. Simulate WS first trade at ID 110 (gap of 5 trades: 105-109).
6. Seed gap detector with the WS first trade ID (simulating JUNC-03 re-seed): `gap_detector.seed(110)`.
7. Now call `gap_detector.check(111)` (next WS trade). Assert result is `None` — no gap detected because detector was seeded past the junction (111 <= 110+1).
8. Call `gap_detector.update_last_tid(111)` to advance state (mirrors what the main loop does after processing a trade).
9. Call `gap_detector.check(112)` (next consecutive WS trade). Assert result is `None` — no gap because 112 <= 111+1.
10. Verify the processor still has its forming bar: `processor.get_incomplete_bar().is_some()` (JUNC-01: forming bar preserved — the test doesn't call puck_fill since that requires network, but verifies the processor state is intact after junction logic).

The test validates: (a) gap detector seeded past junction produces no gap events for subsequent WS trades, (b) processor state preserved (forming bar intact).

```rust
#[test]
fn test_junction_fill_zero_stathera_gaps() {
    // Simulate REST→WS junction scenario
    let (gap_tx, mut gap_rx) = mpsc::channel::<crate::gap::GapEvent>(16);
    let mut gap_detector = crate::gap::TradeIdGapDetector::new(
        Arc::from("TESTUSDT"),
        Some(gap_tx),
    );

    // Simulate fill_from_rest: process trades 100-104
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    for i in 0..5i64 {
        let trade = make_trade(100 + i, 50000.0 + (i as f64) * 0.01, 1700000000000 + (i as u64) * 1000, i % 2 == 0);
        let _ = processor.process_single_trade(&trade);
    }
    assert_eq!(processor.last_agg_trade_id(), Some(104));
    assert!(processor.get_incomplete_bar().is_some(), "forming bar must exist after REST fill");

    // WS first trade arrives at ID 110 (junction gap: 105-109)
    let first_ws_tid = 110i64;

    // JUNC-03: Re-seed gap detector past junction
    gap_detector.seed(first_ws_tid);

    // Subsequent WS trades should NOT trigger gap detection
    // check(111) -> None because 111 <= 110+1
    assert!(gap_detector.check(111).is_none(), "no gap after junction re-seed");
    // Must update_last_tid before next check (check doesn't update state)
    gap_detector.update_last_tid(111);
    // check(112) -> None because 112 <= 111+1
    assert!(gap_detector.check(112).is_none(), "consecutive trade also no gap");

    // No gap events should have been emitted
    assert!(gap_rx.try_recv().is_err(), "no gap events after junction re-seed");

    // Processor forming bar is still intact (JUNC-01)
    assert!(processor.get_incomplete_bar().is_some(), "forming bar preserved across junction");
}
```

</action>
<acceptance_criteria>
- `grep -n "test_junction_fill_zero_stathera_gaps" crates/opendeviationbar-streaming/src/live_engine.rs` returns exactly 1 match
- `cargo nextest run -p opendeviationbar-streaming test_junction_fill_zero_stathera_gaps` passes
- The test contains `gap_detector.seed(first_ws_tid)` (JUNC-03 verification)
- The test asserts `processor.get_incomplete_bar().is_some()` (JUNC-01 verification)
- The test asserts `gap_detector.check(111).is_none()` (Puck suppression verification)
- The test calls `gap_detector.update_last_tid(111)` before `check(112)` (mirrors main loop behavior)
</acceptance_criteria>
</task>

<task id="02-01b">
<name>Add test: zero-gap junction path (first WS trade is exactly last_rest_tid + 1)</name>
<read_first>
- crates/opendeviationbar-streaming/src/live_engine.rs (lines 1453-1670, existing test module)
- crates/opendeviationbar-streaming/src/gap/detector.rs (seed, check API)
</read_first>
<action>
Add a new `#[test]` (synchronous) to the `mod tests` block in `crates/opendeviationbar-streaming/src/live_engine.rs`. The test name: `test_junction_zero_gap_no_fill_needed`.

The test validates the happy path where no junction gap exists: the first WS trade ID is exactly `last_rest_tid + 1`. In this case, `puck_fill()` is NOT called (the `junction_gap > 0` check is false), and the gap detector is still re-seeded.

1. Create a processor and process trades 100-104.
2. Verify `processor.last_agg_trade_id() == Some(104)`.
3. Simulate first WS trade at ID 105 (zero gap: 105 == 104 + 1).
4. Compute `junction_gap = 105 - 104 - 1 = 0`. Assert `junction_gap == 0` (no fill needed).
5. Seed gap detector at 105 (JUNC-03 still applies).
6. Verify `gap_detector.check(106).is_none()` (no gap for next WS trade).
7. Verify processor forming bar is intact.

```rust
#[test]
fn test_junction_zero_gap_no_fill_needed() {
    // Zero-gap junction: first WS trade is exactly last_rest_tid + 1
    let (gap_tx, mut gap_rx) = mpsc::channel::<crate::gap::GapEvent>(16);
    let mut gap_detector = crate::gap::TradeIdGapDetector::new(
        Arc::from("TESTUSDT"),
        Some(gap_tx),
    );

    // REST fill: trades 100-104
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    for i in 0..5i64 {
        let trade = make_trade(100 + i, 50000.0 + (i as f64) * 0.01, 1700000000000 + (i as u64) * 1000, i % 2 == 0);
        let _ = processor.process_single_trade(&trade);
    }
    assert_eq!(processor.last_agg_trade_id(), Some(104));

    // First WS trade at ID 105 — zero gap
    let first_ws_tid = 105i64;
    let last_rest_tid = processor.last_agg_trade_id().unwrap();
    let junction_gap = first_ws_tid - last_rest_tid - 1;
    assert_eq!(junction_gap, 0, "no junction gap when WS continues exactly from REST");

    // JUNC-03: Still re-seed gap detector
    gap_detector.seed(first_ws_tid);

    // Next WS trade should not trigger gap
    assert!(gap_detector.check(106).is_none(), "no gap for consecutive trade after zero-gap junction");

    // No gap events emitted
    assert!(gap_rx.try_recv().is_err(), "no gap events for zero-gap junction");

    // Forming bar intact
    assert!(processor.get_incomplete_bar().is_some(), "forming bar preserved in zero-gap junction");
}
```

</action>
<acceptance_criteria>
- `grep -n "test_junction_zero_gap_no_fill_needed" crates/opendeviationbar-streaming/src/live_engine.rs` returns exactly 1 match
- `cargo nextest run -p opendeviationbar-streaming test_junction_zero_gap_no_fill_needed` passes
- The test asserts `junction_gap == 0`
- The test still calls `gap_detector.seed()` (JUNC-03 applies even for zero-gap)
</acceptance_criteria>
</task>

<task id="02-02">
<name>Add test: monotonicity guard drops overlapping WS trades</name>
<read_first>
- crates/opendeviationbar-streaming/src/live_engine.rs (lines 1453-1670, existing test module)
- crates/opendeviationbar-streaming/src/gap/detector.rs (check returns None for tid <= last_known)
</read_first>
<action>
Add a new `#[test]` (synchronous, not `#[tokio::test]`) to the `mod tests` block in `crates/opendeviationbar-streaming/src/live_engine.rs`. The test name: `test_junction_monotonicity_guard`.

The test validates JUNC-04: WS buffer trades with trade-IDs <= processor's last_agg_trade_id are silently dropped.

1. Create a processor and process trades 100-109 (simulating REST fill + junction fill completing through trade 109).
2. Verify `processor.last_agg_trade_id() == Some(109)`.
3. Simulate the monotonicity check: for WS trades with IDs 107, 108, 109 (overlapping), verify `trade.agg_trade_id <= processor.last_agg_trade_id().unwrap()` is `true` — these would be dropped.
4. For WS trade with ID 110, verify the condition is `false` — this trade would be processed.
5. Process trade 110 through the processor and verify `processor.last_agg_trade_id() == Some(110)`.

```rust
#[test]
fn test_junction_monotonicity_guard() {
    // JUNC-04: Overlapping WS trades must be silently dropped
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Simulate REST + junction fill: trades 100-109
    for i in 0..10i64 {
        let trade = make_trade(100 + i, 50000.0 + (i as f64) * 0.01, 1700000000000 + (i as u64) * 1000, i % 2 == 0);
        let _ = processor.process_single_trade(&trade);
    }
    assert_eq!(processor.last_agg_trade_id(), Some(109));

    // Overlapping WS trades (IDs 107-109) should be dropped
    let last_tid = processor.last_agg_trade_id().unwrap();
    for overlap_id in [107i64, 108, 109] {
        assert!(
            overlap_id <= last_tid,
            "trade {overlap_id} should be dropped (monotonicity guard)"
        );
    }

    // Non-overlapping trade (ID 110) should be processed
    assert!(110 > last_tid, "trade 110 should pass monotonicity guard");
    let trade_110 = make_trade(110, 50000.10, 1700000010000, true);
    let _ = processor.process_single_trade(&trade_110);
    assert_eq!(processor.last_agg_trade_id(), Some(110));
}
```

</action>
<acceptance_criteria>
- `grep -n "test_junction_monotonicity_guard" crates/opendeviationbar-streaming/src/live_engine.rs` returns exactly 1 match
- `cargo nextest run -p opendeviationbar-streaming test_junction_monotonicity_guard` passes
- The test asserts overlap detection for trade IDs 107, 108, 109
- The test asserts trade 110 passes the guard and advances `last_agg_trade_id`
</acceptance_criteria>
</task>

<task id="02-03">
<name>Add test: forming bar trade count preserved across junction</name>
<read_first>
- crates/opendeviationbar-streaming/src/live_engine.rs (lines 1453-1670, existing test module)
- crates/opendeviationbar-core/src/processor.rs (get_incomplete_bar returns OpenDeviationBar with individual_trade_count)
</read_first>
<action>
Add a new `#[test]` (synchronous, not `#[tokio::test]`) to the `mod tests` block in `crates/opendeviationbar-streaming/src/live_engine.rs`. The test name: `test_junction_forming_bar_trade_count_preserved`.

The test validates that the forming bar's trade count is >0 after junction fill (success criterion 3: forming bar trade count preserved).

1. Create a processor with threshold 250.
2. Process 20 trades (IDs 1-20) at prices that do NOT breach the threshold (small increments: 50000.00 + i\*0.001). This builds a forming bar with 20 trades.
3. Verify `processor.get_incomplete_bar()` is `Some` and the bar's `trade_count >= 20`.
4. Process 5 more trades (IDs 21-25) — simulating junction fill trades going through the SAME processor. Prices still non-breaching.
5. Verify `processor.get_incomplete_bar()` is still `Some` and `trade_count >= 25` — the forming bar accumulated trades from both REST and junction fill phases.
6. Process 1 more trade (ID 26) — simulating the first WS trade post-junction. Still non-breaching.
7. Verify `trade_count >= 26` — the forming bar spans all three phases (REST, junction, WS).

```rust
#[test]
fn test_junction_forming_bar_trade_count_preserved() {
    // JUNC-01: Forming bar must carry trades across REST→junction→WS
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Phase 1: REST fill — 20 trades (no breach at 250 dbps = 2.5%)
    for i in 0..20i64 {
        let trade = make_trade(
            i + 1,
            50000.0 + (i as f64) * 0.001, // tiny increments, no breach
            1700000000000 + (i as u64) * 1000,
            i % 2 == 0,
        );
        let result = processor.process_single_trade(&trade).unwrap();
        assert!(result.is_none(), "should not breach at tiny price changes");
    }
    let bar_after_rest = processor.get_incomplete_bar().expect("forming bar after REST");
    assert!(bar_after_rest.individual_trade_count >= 20, "forming bar has REST trades");

    // Phase 2: Junction fill — 5 more trades through SAME processor
    for i in 20..25i64 {
        let trade = make_trade(
            i + 1,
            50000.0 + (i as f64) * 0.001,
            1700000000000 + (i as u64) * 1000,
            i % 2 == 0,
        );
        let result = processor.process_single_trade(&trade).unwrap();
        assert!(result.is_none(), "still no breach during junction fill");
    }
    let bar_after_junction = processor.get_incomplete_bar().expect("forming bar after junction");
    assert!(
        bar_after_junction.individual_trade_count >= 25,
        "forming bar accumulated junction trades: got {}",
        bar_after_junction.individual_trade_count
    );

    // Phase 3: First WS trade — processed through SAME processor
    let ws_trade = make_trade(26, 50000.025, 1700000025000, true);
    let result = processor.process_single_trade(&ws_trade).unwrap();
    assert!(result.is_none(), "still no breach");
    let bar_after_ws = processor.get_incomplete_bar().expect("forming bar after WS start");
    assert!(
        bar_after_ws.individual_trade_count >= 26,
        "forming bar spans REST+junction+WS: got {}",
        bar_after_ws.individual_trade_count
    );
}
```

</action>
<acceptance_criteria>
- `grep -n "test_junction_forming_bar_trade_count_preserved" crates/opendeviationbar-streaming/src/live_engine.rs` returns exactly 1 match
- `cargo nextest run -p opendeviationbar-streaming test_junction_forming_bar_trade_count_preserved` passes
- The test asserts `individual_trade_count >= 25` after junction phase
- The test asserts `individual_trade_count >= 26` after WS phase
- The test uses a single `processor` instance across all 3 phases (JUNC-02: single-processor continuity)
</acceptance_criteria>
</task>

<task id="02-04">
<name>Run full test suite to verify no regressions</name>
<read_first>
- crates/opendeviationbar-streaming/src/live_engine.rs (the test module, to verify all tests are present)
</read_first>
<action>
Run the full streaming crate test suite to verify all new and existing tests pass:

```bash
cargo nextest run -p opendeviationbar-streaming
```

Also run `cargo check -p opendeviationbar-streaming` and `cargo clippy -p opendeviationbar-streaming -- -D warnings` to verify no build errors or lint warnings were introduced.

If any test fails, fix the issue in the test or implementation code from Plan 01.
</action>
<acceptance_criteria>

- `cargo nextest run -p opendeviationbar-streaming` exits with code 0 (all tests pass)
- `cargo check -p opendeviationbar-streaming` exits with code 0
- `cargo clippy -p opendeviationbar-streaming -- -D warnings` exits with code 0
- Output shows `test_junction_fill_zero_stathera_gaps` passed
- Output shows `test_junction_zero_gap_no_fill_needed` passed
- Output shows `test_junction_monotonicity_guard` passed
- Output shows `test_junction_forming_bar_trade_count_preserved` passed
  </acceptance_criteria>
  </task>

## Verification

```bash
# Run all 4 new junction tests
cargo nextest run -p opendeviationbar-streaming -E 'test(junction)'

# Run full streaming suite (regression check)
cargo nextest run -p opendeviationbar-streaming

# Lint check
cargo clippy -p opendeviationbar-streaming -- -D warnings
```

## must_haves

- [ ] Test proves gap detector seeded past junction produces zero gap events (JUNC-03)
- [ ] Test proves zero-gap junction path skips puck_fill and still re-seeds detector
- [ ] Test proves overlapping WS trades are detected by trade-ID comparison (JUNC-04)
- [ ] Test proves forming bar trade count spans REST + junction + WS phases (JUNC-01, JUNC-02)
- [ ] All existing streaming tests still pass (no regressions)
- [ ] `cargo clippy` passes with zero warnings
