diff --git a/Models/RcbInspectionEvidence.cs b/Models/RcbInspectionEvidence.cs index 3c07d8f55..b243e8503 100644 --- a/Models/RcbInspectionEvidence.cs +++ b/Models/RcbInspectionEvidence.cs @@ -96,9 +96,9 @@ public IReadOnlyList Properties(RcbExportRow row, bool co !string.IsNullOrWhiteSpace(live?.ReportId) ? source : ModelSource), new("DataSet reference", Show(row.DataSetReference), row.IsSourceBacked ? "SCL / effective read-only DataSet binding" : "Discovery / effective read-only DataSet binding"), - Field("Enabled / RptEna", live?.Enabled, string.Empty, source), - Field("Reservation", live?.Reservation, string.Empty, source), - Field("Reserve time (s)", live?.ReserveTime, string.Empty, source), + StateField("Enabled / RptEna", live?.Enabled, Model.Enabled, connected, source), + StateField("Reservation", live?.Reservation, Model.Reservation, connected, source), + StateField("Reserve time (s)", live?.ReserveTime, Model.ReserveTime, connected, source), Field("Owner", live?.Owner, string.Empty, source), Field("Trigger options", live?.TriggerOptions, Model.TriggerOptions, source), Field("Optional fields", live?.OptionalFields, Model.OptionalFields, source), @@ -110,6 +110,35 @@ public IReadOnlyList Properties(RcbExportRow row, bool co return list; } + private RcbInspectionProperty StateField( + string name, string? checkedValue, string? discoveryValue, + bool connected, string liveSource) + { + if (UsableObservation(checkedValue)) + return new(name, checkedValue!.Trim(), liveSource); + + // A successful connect/discovery may already have observed RptEna, + // Resv, and ResvTms. It is historical metadata, NOT the present + // RCB reservation/enable state and never proof of local ownership. + // Source-backed SCL declarations do not contain these observations. + if (connected && ModelSource == "Saved discovery model (not live state)" && + UsableObservation(discoveryValue)) + return new(name, discoveryValue!.Trim(), + "Discovery snapshot (historical; explicit MMS check for current state)"); + + return new(name, "—", "Current MMS state not verified"); + } + + private static bool UsableObservation(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + var text = value.Trim(); + return text is not ("—" or "-" or "?") && + !text.Equals("Unknown", StringComparison.OrdinalIgnoreCase) && + !text.Equals("Not read", StringComparison.OrdinalIgnoreCase) && + !text.Equals("Unavailable", StringComparison.OrdinalIgnoreCase); + } + private RcbInspectionProperty Field(string name, string? live, string? model, string liveSource) => !string.IsNullOrWhiteSpace(live) ? new(name, live.Trim(), liveSource) diff --git a/tests/ARSAS.Tests/M214RcbDiscoveryStateProjectionTests.cs b/tests/ARSAS.Tests/M214RcbDiscoveryStateProjectionTests.cs new file mode 100644 index 000000000..4ab70fd7c --- /dev/null +++ b/tests/ARSAS.Tests/M214RcbDiscoveryStateProjectionTests.cs @@ -0,0 +1,93 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M214RcbDiscoveryStateProjectionTests +{ + private static readonly RcbExportRow Row = new() + { + Name = "urcb01", Reference = "LD/LLN0.RP.urcb01", + DataSetReference = "LD/LLN0.Signals" + }; + + private static LiveIedReportControlModel Discovery( + string enabled = "true", string reservation = "false", + string reserveTime = "0") => new() + { + Reference = Row.Reference, + Name = Row.Name, + EnabledState = enabled, ReservationState = reservation, + ReservationTimeSeconds = reserveTime + }; + + private static RcbInspectionProperty Property( + RcbInspectionEvidence evidence, bool connected, string field) + => Assert.Single(evidence.Properties(Row, connected), + property => property.Property == field); + + [Fact] + public void DiscoveryValuesAppearAsHistorical_NotAsCurrentReadOnlyCheck() + { + var evidence = RcbInspectionEvidence.FromDiscovery(Discovery(), null); + foreach (var (name, value) in new[] + { + ("Enabled / RptEna", "true"), + ("Reservation", "false"), + ("Reserve time (s)", "0") + }) + { + var field = Property(evidence, true, name); + Assert.Equal(value, field.Value); + Assert.Contains("historical", field.Source, StringComparison.OrdinalIgnoreCase); + } + Assert.Contains("no current MMS state check", evidence.Summary(true)); + } + + [Fact] + public void NoLiveStateIsProjectedAfterDisconnect_OrFromSclOnly() + { + var saved = RcbInspectionEvidence.FromDiscovery(Discovery(), null); + Assert.Equal("—", Property(saved, false, "Enabled / RptEna").Value); + Assert.Equal("—", Property(saved, false, "Reservation").Value); + Assert.Equal("—", Property(saved, false, "Reserve time (s)").Value); + + var fromScl = RcbInspectionEvidence.FromSource("Rpt01", null); + Assert.Equal("—", Property(fromScl, true, "Enabled / RptEna").Value); + Assert.Equal("—", Property(fromScl, true, "Reservation").Value); + } + + [Fact] + public void UnknownSentinelsNeverClaimRcbDiscoveryState() + { + var evidence = RcbInspectionEvidence.FromDiscovery( + Discovery("Unknown", "Not read", "—"), null); + Assert.Equal("—", Property(evidence, true, "Enabled / RptEna").Value); + Assert.Equal("—", Property(evidence, true, "Reservation").Value); + Assert.Equal("—", Property(evidence, true, "Reserve time (s)").Value); + } + + [Fact] + public void ExplicitSnapshotWinsOverContradictoryDiscovery() + { + var snapshot = new MmsRcbAvailabilitySnapshot + { + EnabledState = "false", ReservationState = "true", + ReservationTimeSeconds = "12" + }; + var evidence = RcbInspectionEvidence.FromDiscovery( + Discovery("true", "false", "0"), snapshot); + foreach (var (name, expected) in new[] + { + ("Enabled / RptEna", "false"), + ("Reservation", "true"), + ("Reserve time (s)", "12") + }) + { + var field = Property(evidence, true, name); + Assert.Equal(expected, field.Value); + Assert.Equal("Read-only MMS availability snapshot", field.Source); + } + } +}