bacnet-objects 0.13.0

BACnet object model: traits, database, and standard object types
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
//! Clause 13.2.2 fault-precedence tests.
//!
//! Reliability had no path into event-state-detection before this (#167), so
//! `EventState::FAULT` was unreachable and every assertion here would have been
//! unwritable. The readings these pin come from a normative pass over the
//! standard; where the standard is silent, the test says so rather than
//! implying a citation exists.

use super::*;

const NO_FAULT: Reliability = Reliability::NO_FAULT_DETECTED;
const OVER_RANGE: Reliability = Reliability::OVER_RANGE;
const NO_SENSOR: Reliability = Reliability::NO_SENSOR;
const SHORTED_LOOP: Reliability = Reliability::SHORTED_LOOP;

const TO_OFFNORMAL: EventTransitionBits = EventTransitionBits::TO_OFFNORMAL;
const TO_FAULT: EventTransitionBits = EventTransitionBits::TO_FAULT;
const TO_NORMAL: EventTransitionBits = EventTransitionBits::TO_NORMAL;

#[test]
fn event_transition_classifier_covers_every_event_state_and_bit() {
    let cases = [
        (EventState::NORMAL, EventTransition::ToNormal),
        (EventState::FAULT, EventTransition::ToFault),
        (EventState::OFFNORMAL, EventTransition::ToOffnormal),
        (EventState::HIGH_LIMIT, EventTransition::ToOffnormal),
        (EventState::LOW_LIMIT, EventTransition::ToOffnormal),
        (EventState::LIFE_SAFETY_ALARM, EventTransition::ToOffnormal),
    ];
    for (state, expected_transition) in cases {
        let transition = EventTransition::for_target_state(state);
        assert_eq!(transition, expected_transition, "state {}", state.to_raw());
    }

    let bits = [
        (EventTransition::ToOffnormal, TO_OFFNORMAL),
        (EventTransition::ToFault, TO_FAULT),
        (EventTransition::ToNormal, TO_NORMAL),
    ];
    for (transition, expected_bit) in bits {
        assert_eq!(transition.bit_mask(), expected_bit, "{transition:?}");
    }
}

/// High limit 80, low limit 20, deadband 2, no delay, all transitions enabled.
fn detector() -> OutOfRangeDetector {
    OutOfRangeDetector {
        high_limit: 80.0,
        low_limit: 20.0,
        deadband: 2.0,
        limit_enable: LimitEnable::all(),
        notification_class: 1,
        notify_type: NotifyType::ALARM,
        event_enable: EventTransitionBits::all(),
        time_delay: 0,
        time_delay_normal: None,
        event_state: EventState::NORMAL,
        acked_transitions: EventTransitionBits::all(),
        pending: None,
        fault_reliability: None,
    }
}

// --- the rule itself ---

#[test]
fn fault_precedence_truth_table() {
    // Bad reliability outside FAULT: transition in.
    assert_eq!(
        fault_precedence(OVER_RANGE, None, EventState::NORMAL),
        FaultPrecedence::EnterFault
    );
    assert_eq!(
        fault_precedence(OVER_RANGE, None, EventState::HIGH_LIMIT),
        FaultPrecedence::EnterFault
    );
    // Unchanged bad reliability already in FAULT: hold.
    assert_eq!(
        fault_precedence(OVER_RANGE, Some(OVER_RANGE), EventState::FAULT),
        FaultPrecedence::HoldFault
    );
    // Changed bad reliability already in FAULT: re-enter.
    assert_eq!(
        fault_precedence(NO_SENSOR, Some(OVER_RANGE), EventState::FAULT),
        FaultPrecedence::ReenterFault
    );
    // In FAULT with no recorded value: re-enter, do not hold. The crate never
    // produces this state, but both fields are public so a downstream implementor
    // can. Re-entering is self-healing — it stores the value and the invariant
    // holds afterwards — whereas holding stores nothing, so the field would stay
    // None and every later genuine change would land here and hold again.
    assert_eq!(
        fault_precedence(OVER_RANGE, None, EventState::FAULT),
        FaultPrecedence::ReenterFault
    );
    // Recovered while in FAULT.
    assert_eq!(
        fault_precedence(NO_FAULT, Some(OVER_RANGE), EventState::FAULT),
        FaultPrecedence::RecoverToNormal
    );
    // No fault in play.
    assert_eq!(
        fault_precedence(NO_FAULT, None, EventState::NORMAL),
        FaultPrecedence::RunAlgorithm
    );
    assert_eq!(
        fault_precedence(NO_FAULT, None, EventState::HIGH_LIMIT),
        FaultPrecedence::RunAlgorithm
    );
}

#[test]
fn any_non_zero_reliability_faults_not_just_a_known_one() {
    // Clause 13.2.2 tests inequality with NO_FAULT_DETECTED, not
    // membership in a list, so an unmodeled value must fault too.
    for reliability in [OVER_RANGE, SHORTED_LOOP, Reliability::from_raw(9999)] {
        let mut det = detector();
        assert_eq!(
            det.probe(50.0, reliability).unwrap().change.to,
            EventState::FAULT
        );
    }
}

// --- entry ---

#[test]
fn bad_reliability_drives_event_state_to_fault() {
    let mut det = detector();
    let outcome = det.probe(50.0, OVER_RANGE).expect("fault transition");
    assert_eq!(outcome.change.from, EventState::NORMAL);
    assert_eq!(outcome.change.to, EventState::FAULT);
    assert_eq!(det.event_state, EventState::FAULT);
}

#[test]
fn fault_takes_precedence_over_the_algorithm() {
    // Present value is above the high limit, so the algorithm alone would say
    // HIGH_LIMIT. Clause 13.2.2 gives fault detection priority over
    // normal/offnormal evaluation.
    let mut det = detector();
    assert_eq!(
        det.probe(99.0, OVER_RANGE).unwrap().change.to,
        EventState::FAULT
    );
}

#[test]
fn fault_entry_ignores_time_delay() {
    // Clause 13.2.2.1's ToFault transition carries no delay term; Time_Delay is
    // an event-algorithm parameter (Clause 13.3.1), and the algorithm is what
    // fault detection overrides. A delay of 10s must not defer the fault.
    let mut det = detector();
    det.time_delay = 10;
    let outcome = det
        .probe(50.0, OVER_RANGE)
        .expect("fault fires immediately");
    assert_eq!(outcome.change.to, EventState::FAULT);
    assert!(det.pending.is_none());
}

#[test]
fn fault_entry_cancels_an_in_flight_countdown() {
    // Project ruling, not a quotation: the standard is silent on the pending
    // timer's fate. Cancelling matches Clause 13.2.2.1.5's treatment of the
    // analogous inhibit case, which restarts the full delay rather than
    // resuming a partial one.
    let mut det = detector();
    det.time_delay = 5;
    assert!(det.probe(99.0, NO_FAULT).is_none(), "countdown seeded");
    assert_eq!(det.pending.as_ref().unwrap().state, EventState::HIGH_LIMIT);

    assert_eq!(
        det.probe(99.0, OVER_RANGE).unwrap().change.to,
        EventState::FAULT
    );
    assert!(
        det.pending.is_none(),
        "pre-fault countdown must be discarded"
    );
}

#[test]
fn holding_fault_blocks_a_state_independent_algorithm() {
    // `HoldFault` is what stops the event algorithm from running while the fault
    // stands. This test uses ChangeOfStateDetector deliberately: its
    // `compute_new_state` is a pure function of the present value, so without
    // the guard it would answer NORMAL for a non-alarm value and fire a
    // spurious FAULT -> NORMAL recovery while Reliability is still bad.
    //
    // OutOfRangeDetector cannot show this. Its `compute_new_state` matches on
    // `self.event_state` and falls through to `_ => self.event_state`, so it is
    // inert in FAULT and the guard is redundant *there* — which is exactly why
    // asserting the hold only against that detector would prove nothing.
    let mut det = ChangeOfStateDetector {
        alarm_values: vec![1],
        event_enable: EventTransitionBits::all(),
        time_delay: 0,
        event_state: EventState::NORMAL,
        ..Default::default()
    };
    assert_eq!(
        det.probe(1, SHORTED_LOOP).unwrap().change.to,
        EventState::FAULT
    );
    assert!(
        det.probe(0, SHORTED_LOOP).is_none(),
        "a non-alarm value must not recover the object while Reliability is bad"
    );
    assert_eq!(det.event_state, EventState::FAULT);
    assert!(det.tick(0, SHORTED_LOOP).is_none());
    assert_eq!(det.event_state, EventState::FAULT);
}

#[test]
fn holding_fault_reports_no_further_transitions() {
    // The standing condition is satisfied once FAULT holds, so re-evaluating with
    // the *same* Reliability value must not re-fire. Note every probe and tick
    // here passes OVER_RANGE unchanged — that is the whole point. Clause 13.2.2.1
    // does define a FAULT re-entry on a *changed* Reliability value, and #217
    // implements it; this test pins the other half of that rule, and is what fails
    // if the re-entry condition is widened to fire whenever the object is faulted
    // rather than only when the value changed.
    let mut det = detector();
    assert!(det.probe(50.0, OVER_RANGE).is_some());
    assert!(det.probe(50.0, OVER_RANGE).is_none());
    assert!(det.tick(50.0, OVER_RANGE).is_none());
    assert!(
        det.probe(99.0, OVER_RANGE).is_none(),
        "the standing fault must not report another transition"
    );
    assert_eq!(det.event_state, EventState::FAULT);
}

#[test]
fn out_of_range_reenters_fault_only_when_reliability_changes() {
    let mut det = detector();

    let mut outcomes = usize::from(det.probe(50.0, OVER_RANGE).is_some());
    for _ in 0..4 {
        outcomes += usize::from(det.tick(50.0, OVER_RANGE).is_some());
    }
    assert_eq!(outcomes, 1, "an unchanged fault must not flood");
    assert_eq!(det.fault_reliability, Some(OVER_RANGE));

    let reentry = det
        .probe(50.0, NO_SENSOR)
        .expect("changed reliability re-enters FAULT");
    assert_eq!(reentry.change.from, EventState::FAULT);
    assert_eq!(reentry.change.to, EventState::FAULT);
    assert_eq!(reentry.event_type, EventType::CHANGE_OF_RELIABILITY);
    assert_eq!(
        EventTransition::for_target_state(reentry.change.to),
        EventTransition::ToFault
    );
    assert_eq!(det.fault_reliability, Some(NO_SENSOR));
    assert!(det.tick(50.0, NO_SENSOR).is_none());

    assert_eq!(
        det.probe(50.0, NO_FAULT).unwrap().change.to,
        EventState::NORMAL
    );
    assert_eq!(det.fault_reliability, None);
    assert_eq!(
        det.probe(50.0, NO_SENSOR).unwrap().change.to,
        EventState::FAULT
    );
}

#[test]
fn change_of_state_reenters_fault_only_when_reliability_changes() {
    let mut det = ChangeOfStateDetector {
        alarm_values: vec![1],
        event_enable: EventTransitionBits::all(),
        ..Default::default()
    };

    let mut outcomes = usize::from(det.probe(0, OVER_RANGE).is_some());
    for _ in 0..4 {
        outcomes += usize::from(det.tick(0, OVER_RANGE).is_some());
    }
    assert_eq!(outcomes, 1, "an unchanged fault must not flood");
    assert_eq!(det.fault_reliability, Some(OVER_RANGE));

    let reentry = det
        .probe(0, NO_SENSOR)
        .expect("changed reliability re-enters FAULT");
    assert_eq!(reentry.change.from, EventState::FAULT);
    assert_eq!(reentry.change.to, EventState::FAULT);
    assert_eq!(reentry.event_type, EventType::CHANGE_OF_RELIABILITY);
    assert_eq!(
        EventTransition::for_target_state(reentry.change.to),
        EventTransition::ToFault
    );
    assert_eq!(det.fault_reliability, Some(NO_SENSOR));
    assert!(det.tick(0, NO_SENSOR).is_none());

    assert_eq!(
        det.probe(0, NO_FAULT).unwrap().change.to,
        EventState::NORMAL
    );
    assert_eq!(det.fault_reliability, None);
    assert_eq!(
        det.probe(0, NO_SENSOR).unwrap().change.to,
        EventState::FAULT
    );
}

#[test]
fn command_failure_reenters_fault_only_when_reliability_changes() {
    let mut det = CommandFailureDetector {
        event_enable: EventTransitionBits::all(),
        ..Default::default()
    };

    let mut outcomes = usize::from(det.probe(0, 0, OVER_RANGE).is_some());
    for _ in 0..4 {
        outcomes += usize::from(det.tick(0, 0, OVER_RANGE).is_some());
    }
    assert_eq!(outcomes, 1, "an unchanged fault must not flood");
    assert_eq!(det.fault_reliability, Some(OVER_RANGE));

    let reentry = det
        .probe(0, 0, NO_SENSOR)
        .expect("changed reliability re-enters FAULT");
    assert_eq!(reentry.change.from, EventState::FAULT);
    assert_eq!(reentry.change.to, EventState::FAULT);
    assert_eq!(reentry.event_type, EventType::CHANGE_OF_RELIABILITY);
    assert_eq!(
        EventTransition::for_target_state(reentry.change.to),
        EventTransition::ToFault
    );
    assert_eq!(det.fault_reliability, Some(NO_SENSOR));
    assert!(det.tick(0, 0, NO_SENSOR).is_none());

    assert_eq!(
        det.probe(0, 0, NO_FAULT).unwrap().change.to,
        EventState::NORMAL
    );
    assert_eq!(det.fault_reliability, None);
    assert_eq!(
        det.probe(0, 0, NO_SENSOR).unwrap().change.to,
        EventState::FAULT
    );
}

// --- recovery: the reading that refuted the issue's own suggestion ---

#[test]
fn recovery_from_fault_enters_normal_not_the_algorithm_state() {
    // THE keystone assertion. In Clause 13.2.2.1 (Fault, ToNormal), the way
    // out of Fault leads to Normal, with its transition actions, once
    // reliability evaluation is back to NO_FAULT_DETECTED.
    //
    // Issue #167's own "Suggested direction" proposed re-deriving the state from
    // the event algorithm here. That would yield HIGH_LIMIT, because the present
    // value never came back into range — a FAULT -> HIGH_LIMIT transition the
    // state machine does not define. This test is what fails under that design.
    let mut det = detector();
    assert_eq!(
        det.probe(99.0, OVER_RANGE).unwrap().change.to,
        EventState::FAULT
    );

    let outcome = det.probe(99.0, NO_FAULT).expect("recovery transition");
    assert_eq!(outcome.change.from, EventState::FAULT);
    assert_eq!(
        outcome.change.to,
        EventState::NORMAL,
        "recovery must enter NORMAL even though present_value is still out of range"
    );
}

#[test]
fn algorithm_reasserts_offnormal_after_recovery() {
    // Recovery lands in NORMAL, and the algorithm is then free to move the
    // object out of NORMAL under its own conditions — so the out-of-range value
    // is not swallowed, merely re-detected from the state machine's real state.
    let mut det = detector();
    det.probe(99.0, OVER_RANGE);
    det.probe(99.0, NO_FAULT);
    assert_eq!(det.event_state, EventState::NORMAL);

    let outcome = det.probe(99.0, NO_FAULT).expect("algorithm re-detects");
    assert_eq!(outcome.change.from, EventState::NORMAL);
    assert_eq!(outcome.change.to, EventState::HIGH_LIMIT);
}

#[test]
fn recovery_honors_time_delay_on_the_subsequent_offnormal_transition() {
    // The delay applies to the algorithm's transition out of NORMAL, not to the
    // recovery itself — the shape Clause 13.2.2.1.5 uses for the analogous
    // inhibit case, where a condition must persist for the full configured
    // delay again before it produces a transition.
    let mut det = detector();
    det.time_delay = 3;
    det.probe(99.0, OVER_RANGE);

    assert_eq!(
        det.probe(99.0, NO_FAULT).unwrap().change.to,
        EventState::NORMAL,
        "recovery itself is not delayed"
    );
    assert!(det.probe(99.0, NO_FAULT).is_none(), "offnormal is delayed");
    assert_eq!(det.pending.as_ref().unwrap().remaining, 3);
}

// --- what the transition carries ---

#[test]
fn fault_transitions_report_change_of_reliability() {
    // Clause 13.2.5.3 / Table 13-3. This was implemented in #211 but was
    // unreachable until now, so this is the first test to exercise it through a
    // transition an object can actually produce.
    let mut det = detector();
    let into = det.probe(50.0, OVER_RANGE).unwrap().change;
    assert_eq!(
        into.event_type(OutOfRangeDetector::ALGORITHM),
        EventType::CHANGE_OF_RELIABILITY
    );
    assert_eq!(into.transition(), EventTransition::ToFault);

    let out = det.probe(50.0, NO_FAULT).unwrap().change;
    assert_eq!(
        out.event_type(OutOfRangeDetector::ALGORITHM),
        EventType::CHANGE_OF_RELIABILITY
    );
    assert_eq!(out.transition(), EventTransition::ToNormal);
}

#[test]
fn fault_override_wins_in_both_directions_for_every_detector_algorithm() {
    let mut out_of_range = detector();
    let into = out_of_range.probe(50.0, OVER_RANGE).unwrap();
    let out = out_of_range.probe(50.0, NO_FAULT).unwrap();
    assert_eq!(into.event_type, EventType::CHANGE_OF_RELIABILITY);
    assert_eq!(out.event_type, EventType::CHANGE_OF_RELIABILITY);

    let mut change_of_state = ChangeOfStateDetector {
        alarm_values: vec![1],
        event_enable: EventTransitionBits::all(),
        ..Default::default()
    };
    let into = change_of_state.probe(0, OVER_RANGE).unwrap();
    let out = change_of_state.probe(0, NO_FAULT).unwrap();
    assert_eq!(into.event_type, EventType::CHANGE_OF_RELIABILITY);
    assert_eq!(out.event_type, EventType::CHANGE_OF_RELIABILITY);

    let mut command_failure = CommandFailureDetector {
        event_enable: EventTransitionBits::all(),
        ..Default::default()
    };
    let into = command_failure.probe(0, 0, OVER_RANGE).unwrap();
    let out = command_failure.probe(0, 0, NO_FAULT).unwrap();
    assert_eq!(into.event_type, EventType::CHANGE_OF_RELIABILITY);
    assert_eq!(out.event_type, EventType::CHANGE_OF_RELIABILITY);
}

#[test]
fn out_of_range_detector_reports_its_algorithm_for_non_fault_transition() {
    let outcome = detector().probe(99.0, NO_FAULT).unwrap();
    assert_eq!(outcome.change.to, EventState::HIGH_LIMIT);
    assert_eq!(outcome.event_type, EventType::OUT_OF_RANGE);
}

#[test]
fn change_of_state_detector_reports_its_algorithm_for_non_fault_transition() {
    let mut detector = ChangeOfStateDetector {
        alarm_values: vec![1],
        event_enable: EventTransitionBits::all(),
        ..Default::default()
    };
    let outcome = detector.probe(1, NO_FAULT).unwrap();
    assert_eq!(outcome.change.to, EventState::OFFNORMAL);
    assert_eq!(outcome.event_type, EventType::CHANGE_OF_STATE);
}

#[test]
fn command_failure_offnormal_reports_command_failure_not_change_of_state() {
    let mut detector = CommandFailureDetector {
        event_enable: EventTransitionBits::all(),
        ..Default::default()
    };
    let outcome = detector.probe(1, 0, NO_FAULT).unwrap();
    assert_eq!(outcome.change.to, EventState::OFFNORMAL);
    assert_eq!(outcome.event_type, EventType::COMMAND_FAILURE);
}

#[test]
fn fault_distribution_honors_the_to_fault_event_enable_bit() {
    // Clause 13.2.5: Event_Enable scopes distribution, never detection. The
    // transition is reported either way; only `distribute` changes.
    let mut det = detector();
    det.event_enable = TO_FAULT;
    assert!(det.probe(50.0, OVER_RANGE).unwrap().distribute);

    let mut det = detector();
    det.event_enable = TO_OFFNORMAL | TO_NORMAL;
    let outcome = det.probe(50.0, OVER_RANGE).expect("still detected");
    assert!(!outcome.distribute);
    assert_eq!(
        det.event_state,
        EventState::FAULT,
        "a cleared bit must not suppress the Event_State write"
    );
}

#[test]
fn out_of_range_distribution_selects_the_to_normal_bit() {
    for (event_enable, expected) in [(TO_NORMAL, true), (TO_OFFNORMAL | TO_FAULT, false)] {
        let mut det = detector();
        det.event_state = EventState::HIGH_LIMIT;
        det.event_enable = event_enable;

        let outcome = det.probe(50.0, NO_FAULT).expect("TO_NORMAL transition");
        assert_eq!(outcome.change.to, EventState::NORMAL);
        assert_eq!(outcome.distribute, expected, "Event_Enable {event_enable}");
    }
}

#[test]
fn out_of_range_distribution_selects_the_to_offnormal_bit() {
    for (event_enable, expected) in [(TO_OFFNORMAL, true), (TO_FAULT | TO_NORMAL, false)] {
        let mut det = detector();
        det.event_enable = event_enable;

        let outcome = det.probe(90.0, NO_FAULT).expect("TO_OFFNORMAL transition");
        assert_eq!(outcome.change.to, EventState::HIGH_LIMIT);
        assert_eq!(outcome.distribute, expected, "Event_Enable {event_enable}");
    }
}

#[test]
fn change_of_state_distribution_selects_the_to_normal_bit() {
    for (event_enable, expected) in [(TO_NORMAL, true), (TO_OFFNORMAL | TO_FAULT, false)] {
        let mut det = ChangeOfStateDetector {
            alarm_values: vec![1],
            event_enable,
            event_state: EventState::OFFNORMAL,
            ..Default::default()
        };

        let outcome = det.probe(0, NO_FAULT).expect("TO_NORMAL transition");
        assert_eq!(outcome.change.to, EventState::NORMAL);
        assert_eq!(outcome.distribute, expected, "Event_Enable {event_enable}");
    }
}

#[test]
fn change_of_state_distribution_selects_the_to_offnormal_bit() {
    for (event_enable, expected) in [(TO_OFFNORMAL, true), (TO_FAULT | TO_NORMAL, false)] {
        let mut det = ChangeOfStateDetector {
            alarm_values: vec![1],
            event_enable,
            ..Default::default()
        };

        let outcome = det.probe(1, NO_FAULT).expect("TO_OFFNORMAL transition");
        assert_eq!(outcome.change.to, EventState::OFFNORMAL);
        assert_eq!(outcome.distribute, expected, "Event_Enable {event_enable}");
    }
}

#[test]
fn command_failure_distribution_selects_the_to_normal_bit() {
    for (event_enable, expected) in [(TO_NORMAL, true), (TO_OFFNORMAL | TO_FAULT, false)] {
        let mut det = CommandFailureDetector {
            event_enable,
            event_state: EventState::OFFNORMAL,
            ..Default::default()
        };

        let outcome = det.probe(1, 1, NO_FAULT).expect("TO_NORMAL transition");
        assert_eq!(outcome.change.to, EventState::NORMAL);
        assert_eq!(outcome.distribute, expected, "Event_Enable {event_enable}");
    }
}

#[test]
fn command_failure_distribution_selects_the_to_offnormal_bit() {
    for (event_enable, expected) in [(TO_OFFNORMAL, true), (TO_FAULT | TO_NORMAL, false)] {
        let mut det = CommandFailureDetector {
            event_enable,
            ..Default::default()
        };

        let outcome = det.probe(1, 0, NO_FAULT).expect("TO_OFFNORMAL transition");
        assert_eq!(outcome.change.to, EventState::OFFNORMAL);
        assert_eq!(outcome.distribute, expected, "Event_Enable {event_enable}");
    }
}

// --- the other two detectors ---

#[test]
fn change_of_state_detector_applies_fault_precedence() {
    let mut det = ChangeOfStateDetector {
        alarm_values: vec![1],
        event_enable: EventTransitionBits::all(),
        time_delay: 0,
        event_state: EventState::NORMAL,
        ..Default::default()
    };
    assert_eq!(
        det.probe(0, SHORTED_LOOP).unwrap().change.to,
        EventState::FAULT
    );
    // Recovery enters NORMAL even though the present value is an alarm value.
    assert_eq!(
        det.probe(1, NO_FAULT).unwrap().change.to,
        EventState::NORMAL
    );
}

#[test]
fn command_failure_detector_applies_fault_precedence() {
    let mut det = CommandFailureDetector {
        event_enable: EventTransitionBits::all(),
        time_delay: 0,
        event_state: EventState::NORMAL,
        ..Default::default()
    };
    assert_eq!(
        det.probe(1, 1, SHORTED_LOOP).unwrap().change.to,
        EventState::FAULT
    );
}

#[test]
fn change_of_state_fault_entry_discards_an_in_flight_countdown() {
    // Apply the same project ruling as the out-of-range detector: a countdown
    // seeded before FAULT must not resume after recovery.
    let mut det = ChangeOfStateDetector {
        alarm_values: vec![1],
        event_enable: EventTransitionBits::all(),
        time_delay: 2,
        event_state: EventState::NORMAL,
        ..Default::default()
    };
    assert!(det.probe(1, NO_FAULT).is_none(), "countdown seeded");
    assert_eq!(det.pending.as_ref().unwrap().state, EventState::OFFNORMAL);
    assert_eq!(
        det.probe(1, SHORTED_LOOP).unwrap().change.to,
        EventState::FAULT
    );
    assert!(det.pending.is_none(), "pre-fault countdown is discarded");

    assert_eq!(
        det.probe(1, NO_FAULT).unwrap().change.to,
        EventState::NORMAL
    );
    assert!(det.tick(1, NO_FAULT).is_none(), "a fresh countdown starts");
    assert_eq!(det.pending.as_ref().unwrap().remaining, 2);
}

#[test]
fn command_failure_holds_fault_while_reliability_remains_bad() {
    // Matching values make the algorithm answer NORMAL, pinning HoldFault.
    let mut det = CommandFailureDetector {
        event_enable: EventTransitionBits::all(),
        event_state: EventState::NORMAL,
        ..Default::default()
    };
    assert_eq!(
        det.probe(1, 1, SHORTED_LOOP).unwrap().change.to,
        EventState::FAULT
    );
    assert!(det.probe(1, 1, SHORTED_LOOP).is_none());
    assert!(det.tick(1, 1, SHORTED_LOOP).is_none());
    assert_eq!(det.event_state, EventState::FAULT);
}

#[test]
fn command_failure_recovers_to_normal_before_rerunning_its_algorithm() {
    let mut det = CommandFailureDetector {
        event_enable: EventTransitionBits::all(),
        event_state: EventState::NORMAL,
        ..Default::default()
    };
    assert_eq!(
        det.probe(1, 1, SHORTED_LOOP).unwrap().change.to,
        EventState::FAULT
    );
    let recovery = det
        .tick(1, 0, NO_FAULT)
        .expect("healthy reliability recovers through the periodic path");
    assert_eq!(recovery.change.from, EventState::FAULT);
    assert_eq!(recovery.change.to, EventState::NORMAL);
    assert_eq!(
        det.probe(1, 0, NO_FAULT).unwrap().change.to,
        EventState::OFFNORMAL
    );
}

#[test]
fn command_failure_fault_entry_discards_an_in_flight_countdown() {
    let mut det = CommandFailureDetector {
        event_enable: EventTransitionBits::all(),
        time_delay: 2,
        event_state: EventState::NORMAL,
        ..Default::default()
    };
    assert!(det.probe(1, 0, NO_FAULT).is_none(), "countdown seeded");
    assert_eq!(det.pending.as_ref().unwrap().state, EventState::OFFNORMAL);
    assert_eq!(
        det.tick(1, 0, SHORTED_LOOP).unwrap().change.to,
        EventState::FAULT
    );
    assert!(det.pending.is_none(), "pre-fault countdown is discarded");
}

// --- object level: the macro wiring reaches real objects ---

#[test]
fn writing_reliability_on_an_object_drives_event_state_to_fault() {
    // Proves the `impl_builtin_intrinsic_reporting!` wiring, which is what carries
    // reliability into detection for all nine intrinsically-reporting object
    // types. Reliability arrives by the ordinary property-write route, so this
    // also covers the case the server's fault detector is not involved in at
    // all — a local or network write of Reliability.
    use crate::analog::AnalogInputObject;
    use crate::traits::BACnetObject;
    use bacnet_types::enums::PropertyIdentifier;
    use bacnet_types::primitives::PropertyValue;

    let mut ai = AnalogInputObject::new(1, "ai-1", 62).expect("construct");
    assert!(
        ai.evaluate_intrinsic_reporting().is_none(),
        "healthy object has no transition to report"
    );

    ai.write_property(
        PropertyIdentifier::OUT_OF_SERVICE,
        None,
        PropertyValue::Boolean(true),
        None,
    )
    .expect("Out_Of_Service is writable");
    ai.write_property(
        PropertyIdentifier::RELIABILITY,
        None,
        PropertyValue::Enumerated(OVER_RANGE.to_raw()),
        None,
    )
    .expect("reliability is writable");

    let outcome = ai
        .evaluate_intrinsic_reporting()
        .expect("reliability must drive a transition");
    assert_eq!(outcome.change.to, EventState::FAULT);
    assert_eq!(
        outcome.change.event_type(OutOfRangeDetector::ALGORITHM),
        EventType::CHANGE_OF_RELIABILITY
    );
}

#[test]
fn ticking_an_object_uses_reliability_for_fault_and_recovery() {
    // The running server drives this periodic macro arm.
    use crate::analog::AnalogInputObject;
    use crate::traits::BACnetObject;
    use bacnet_types::enums::PropertyIdentifier;
    use bacnet_types::primitives::PropertyValue;

    let mut ai = AnalogInputObject::new(3, "ai-3", 62).expect("construct");
    ai.write_property(
        PropertyIdentifier::OUT_OF_SERVICE,
        None,
        PropertyValue::Boolean(true),
        None,
    )
    .expect("Out_Of_Service is writable");
    ai.write_property(
        PropertyIdentifier::RELIABILITY,
        None,
        PropertyValue::Enumerated(OVER_RANGE.to_raw()),
        None,
    )
    .expect("reliability is writable");
    let proposal = ai
        .tick_intrinsic_reporting()
        .expect("periodic tick must observe bad reliability");
    let fault = commit_test_proposal(&mut ai, proposal);
    assert_eq!(fault.change.to, EventState::FAULT);

    ai.write_property(
        PropertyIdentifier::RELIABILITY,
        None,
        PropertyValue::Enumerated(NO_FAULT.to_raw()),
        None,
    )
    .expect("reliability is writable");
    let recovery = ai
        .tick_intrinsic_reporting()
        .expect("periodic tick must observe recovered reliability");
    assert_eq!(recovery.change.from, EventState::FAULT);
    assert_eq!(recovery.change.to, EventState::NORMAL);
}

#[test]
fn faulted_object_reports_both_fault_and_in_alarm_status_flags() {
    // Clause 12.2 derives IN_ALARM from Event_State and FAULT from Reliability,
    // without specifying how the individual flags relate to each other.
    // They are reconciled upstream: while event-state-detection is
    // enabled, Clause 13.2.2 makes a bad Reliability determine Event_State =
    // FAULT, so both flags read TRUE together. Before #167 this object reported
    // FAULT TRUE with IN_ALARM FALSE, because Reliability never reached
    // Event_State.
    use crate::analog::AnalogInputObject;
    use crate::traits::BACnetObject;
    use bacnet_types::enums::PropertyIdentifier;
    use bacnet_types::primitives::{PropertyValue, StatusFlags};

    let mut ai = AnalogInputObject::new(2, "ai-2", 62).expect("construct");
    ai.set_reliability_internal(OVER_RANGE)
        .expect("in-service reliability evaluation is supported");
    let proposal = ai
        .evaluate_intrinsic_reporting()
        .expect("bad reliability must propose FAULT");
    commit_test_proposal(&mut ai, proposal);

    let flags = ai
        .read_property(PropertyIdentifier::STATUS_FLAGS, None)
        .expect("status flags readable");
    let PropertyValue::BitString { data, .. } = flags else {
        panic!("Status_Flags is a bit string");
    };
    let bits = data[0] >> 4;
    assert_ne!(
        bits & StatusFlags::FAULT.bits(),
        0,
        "FAULT follows Reliability"
    );
    assert_ne!(
        bits & StatusFlags::IN_ALARM.bits(),
        0,
        "IN_ALARM follows Event_State, which Reliability now drives"
    );
}

#[test]
fn command_failure_to_fault_distribution_is_no_longer_hardcoded_off() {
    // #200: this detector's `fire` returned `distribute: false` for FAULT
    // unconditionally, which was unobservable while FAULT was unreachable.
    let mut det = CommandFailureDetector {
        event_enable: TO_FAULT,
        time_delay: 0,
        event_state: EventState::NORMAL,
        ..Default::default()
    };
    assert!(
        det.probe(1, 1, SHORTED_LOOP).unwrap().distribute,
        "TO_FAULT distribution must follow Event_Enable like the other two directions"
    );

    let mut det = CommandFailureDetector {
        event_enable: TO_OFFNORMAL | TO_NORMAL,
        time_delay: 0,
        event_state: EventState::NORMAL,
        ..Default::default()
    };
    assert!(!det.probe(1, 1, SHORTED_LOOP).unwrap().distribute);
}