Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions SaveSclWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
</LinearGradientBrush>
<Style x:Key="EditionToggleButton" TargetType="ToggleButton">
<Setter Property="Height" Value="44"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="ClipToBounds" Value="False"/>
<Setter Property="Background" Value="#F8FAFD"/>
<Setter Property="BorderBrush" Value="#D7E1EF"/>
<Setter Property="BorderThickness" Value="1"/>
Expand Down Expand Up @@ -116,9 +119,10 @@

<StackPanel Grid.Row="2" Margin="0,22,0,0">
<TextBlock Text="IEC 61850 edition" FontSize="12.2" FontWeight="SemiBold" Foreground="#475467" Margin="1,0,0,8"/>
<Border x:Name="EditionIndicator" Width="390" Height="52" HorizontalAlignment="Left"
Background="#EEF3FA" BorderBrush="#D7E1EF" BorderThickness="1" CornerRadius="18" Padding="4">
<Grid>
<Border x:Name="EditionIndicator" Width="390" Height="60" HorizontalAlignment="Left"
Background="#EEF3FA" BorderBrush="#D7E1EF" BorderThickness="1" CornerRadius="18"
Padding="5,6" ClipToBounds="False">
<Grid ClipToBounds="False">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
Expand Down Expand Up @@ -166,4 +170,4 @@
</Button>
</StackPanel>
</Grid>
</Window>
</Window>
43 changes: 37 additions & 6 deletions Services/RcbExportEvidencePolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,44 @@ public static string EffectiveDataSetReference(
public static int EffectiveMemberCount(int liveMemberCount, int fallbackMemberCount)
=> liveMemberCount > 0 ? liveMemberCount : Math.Max(0, fallbackMemberCount);

/// <summary>
/// True when the source SCL intentionally/legitimately leaves the ReportControl
/// datSet association open and a successful live read shows a runtime DataSet binding.
/// This is normal dynamic-RCB evidence and must never be presented as configuration
/// mismatch merely because the source side is blank.
/// </summary>
public static bool IsDynamicRuntimeBinding(
string? sourceReference,
string? liveReference,
MmsRcbDataSetProbeState liveProbeState)
=> liveProbeState == MmsRcbDataSetProbeState.ReadSucceeded &&
string.IsNullOrWhiteSpace(NormalizeReference(sourceReference)) &&
!string.IsNullOrWhiteSpace(NormalizeReference(liveReference));

public static bool HasSourceLiveBindingConflict(
string? sourceReference,
string? liveReference,
MmsRcbDataSetProbeState liveProbeState)
{
// Only positive live binding evidence may contradict the source SCL. A failed or
// unattempted live read is unresolved evidence, not a configuration mismatch.
// Only positive live binding evidence may contradict a fixed source binding.
// A failed or unattempted live read is unresolved evidence, not a mismatch.
if (liveProbeState != MmsRcbDataSetProbeState.ReadSucceeded)
return false;

var source = NormalizeReference(sourceReference);
var live = NormalizeReference(liveReference);
if (source.Length == 0 && live.Length == 0)

// An unbound source ReportControl is not a promise that the live DatSet must stay
// empty. Dynamic RCB workflows are allowed to bind a DataSet at runtime, so blank
// source + populated live binding is valid evidence rather than a conflict.
if (source.Length == 0)
return false;
if (source.Length == 0 || live.Length == 0)

// A fixed source binding *is* a contract. A successful live read proving no binding,
// or proving a different binding, is therefore a real configuration mismatch.
if (live.Length == 0)
return true;

return !source.Equals(live, StringComparison.OrdinalIgnoreCase);
}

Expand All @@ -60,7 +82,12 @@ public static MmsRcbOperationalAvailability SourceAvailability(

var hasConfiguredBinding = !string.IsNullOrWhiteSpace(configuredDataSetName);
if (!hasConfiguredBinding)
return MmsRcbOperationalAvailability.NoDataSet;
{
// A blank source datSet is valid for a dynamic RCB. Before a live DatSet read
// proves the current runtime association, keep the state unknown/informational
// instead of painting the row as an operational NoDataSet failure.
return MmsRcbOperationalAvailability.Unknown;
}

if (!dataSetResolved)
return MmsRcbOperationalAvailability.Unknown;
Expand Down Expand Up @@ -95,7 +122,11 @@ public static string SourceReason(
bool connected)
{
if (string.IsNullOrWhiteSpace(configuredDataSetName))
return "The source SCL ReportControl has no configured datSet binding.";
{
return connected
? "The source SCL leaves the ReportControl datSet unbound. This is valid for a dynamic RCB; use Check Availability to read the current live binding."
: "The source SCL leaves the ReportControl datSet unbound. This can be a valid dynamic RCB; connect the IED to read the current live binding.";
}

if (!dataSetResolved)
return "The source SCL ReportControl names a DataSet, but that reference does not resolve in the same Logical Node.";
Expand Down
45 changes: 39 additions & 6 deletions tests/ARSAS.Tests/RcbExportEvidencePolicyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ public void E016Style_ConfiguredCtrlUrcb_IsNeverNoDataSetBeforeLiveCheck()
}

[Fact]
public void E016Style_UnboundExtUrcb_IsNoDataSetFromPositiveSclEvidence()
public void SourceUnboundDynamicRcb_RemainsUnknownBeforeLiveCheck_NotOperationalFailure()
{
var availability = RcbExportEvidencePolicy.SourceAvailability(
liveAvailability: null,
configuredDataSetName: string.Empty,
dataSetResolved: false,
configuredMemberCount: 0);

Assert.Equal(MmsRcbOperationalAvailability.NoDataSet, availability);
Assert.Equal(MmsRcbOperationalAvailability.Unknown, availability);
Assert.NotEqual(MmsRcbOperationalAvailability.NoDataSet, availability);
}

[Fact]
Expand Down Expand Up @@ -99,21 +100,40 @@ public void SourceConfiguredButLiveVerifiedNone_IsConfigurationConflict()
}

[Fact]
public void SourceUnboundButLiveVerifiedDataSet_IsConfigurationConflict()
public void SourceUnboundButLiveVerifiedDataSet_IsDynamicBinding_NotConfigurationConflict()
{
Assert.True(RcbExportEvidencePolicy.HasSourceLiveBindingConflict(
const string live = "AA1C1F13R4Application/LLN0.AR_HYB_01";

Assert.True(RcbExportEvidencePolicy.IsDynamicRuntimeBinding(
string.Empty,
"E016MD66CTRL/LLN0.DataSet",
live,
MmsRcbDataSetProbeState.ReadSucceeded));
Assert.False(RcbExportEvidencePolicy.HasSourceLiveBindingConflict(
string.Empty,
live,
MmsRcbDataSetProbeState.ReadSucceeded));
}

[Fact]
public void SourceConfiguredAndLiveVerifiedDifferentDataSet_IsConfigurationConflict()
{
Assert.True(RcbExportEvidencePolicy.HasSourceLiveBindingConflict(
"AA1C1F13R4Application/LLN0.StaticSet",
"AA1C1F13R4Application/LLN0.AR_HYB_01",
MmsRcbDataSetProbeState.ReadSucceeded));
}

[Fact]
public void FailedLiveRead_DoesNotCreateFalseConfigurationConflict()
public void FailedLiveRead_DoesNotCreateFalseConfigurationConflictOrDynamicBinding()
{
Assert.False(RcbExportEvidencePolicy.HasSourceLiveBindingConflict(
"E016MD66CTRL/LLN0.DataSet",
string.Empty,
MmsRcbDataSetProbeState.ReadFailed));
Assert.False(RcbExportEvidencePolicy.IsDynamicRuntimeBinding(
string.Empty,
"E016MD66CTRL/LLN0.RuntimeSet",
MmsRcbDataSetProbeState.ReadFailed));
}

[Fact]
Expand All @@ -125,6 +145,19 @@ public void EquivalentSourceAndLiveBindings_AreNotConfigurationConflict()
MmsRcbDataSetProbeState.ReadSucceeded));
}

[Fact]
public void DynamicSourceReason_ExplainsUnboundSourceAsValidBeforeLiveCheck()
{
var reason = RcbExportEvidencePolicy.SourceReason(
configuredDataSetName: string.Empty,
dataSetResolved: false,
configuredMemberCount: 0,
connected: true);

Assert.Contains("valid for a dynamic RCB", reason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Check Availability", reason, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void DuplicateShortRcbNames_AreDistinguishedByLogicalScope()
{
Expand Down
54 changes: 54 additions & 0 deletions tests/ARSAS.Tests/SaveSclEditionToggleClippingRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Xml.Linq;

namespace ARSAS.Tests;

public sealed class SaveSclEditionToggleClippingRegressionTests
{
[Fact]
public void EditionToggle_ReservesInternalVerticalSafeAreaAndUsesInternalFocusCue()
{
var source = File.ReadAllText(FindRepoFile("SaveSclWindow.xaml"));
var document = XDocument.Parse(source);
XNamespace presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";

var indicator = document.Descendants(presentation + "Border")
.Single(node => (string?)node.Attribute(x + "Name") == "EditionIndicator");

Assert.Equal("60", (string?)indicator.Attribute("Height"));
Assert.Equal("5,6", (string?)indicator.Attribute("Padding"));
Assert.Equal("False", (string?)indicator.Attribute("ClipToBounds"));

var style = document.Descendants(presentation + "Style")
.Single(node => (string?)node.Attribute(x + "Key") == "EditionToggleButton");
var setters = style.Elements(presentation + "Setter").ToArray();

string? SetterValue(string property)
=> setters.Single(node => (string?)node.Attribute("Property") == property)
.Attribute("Value")?.Value;

Assert.Equal("44", SetterValue("Height"));
Assert.Equal("Center", SetterValue("VerticalAlignment"));
Assert.Equal("{x:Null}", SetterValue("FocusVisualStyle"));
Assert.Equal("False", SetterValue("ClipToBounds"));

// 60 DIP shell with 6 DIP top/bottom padding leaves intentional breathing room
// around the 44 DIP toggle instead of the old exact 4 + 44 + 4 = 52 fit.
Assert.True(60 - (6 + 6) > 44);
}

private static string FindRepoFile(string relativePath)
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory != null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
return candidate;
directory = directory.Parent;
}

throw new FileNotFoundException(
$"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'.");
}
}
Loading