Skip to content
74 changes: 74 additions & 0 deletions Models/IoTesting/IoFatSourceModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,63 @@ public static string ProjectSourceFingerprint(IoTestProject project)
return string.Empty;
}

/// <summary>
/// Content-only fingerprint used exclusively for SCL workspace continuation.
/// File names and SourceId values are presentation/staging metadata and must not make
/// the same IEC 61850 source bytes look like a different FAT project after reopening
/// Engineering and switching back to FAT.
///
/// Do not use this fingerprint for workbook identity or package integrity. Those paths
/// retain the strict source-set contract above.
/// </summary>
public static string ComputeContentFingerprint(IEnumerable<IoFatSourceDescriptor> sources)
{
ArgumentNullException.ThrowIfNull(sources);
var canonical = sources
.Select(Normalize)
.OrderBy(source => source.Kind, StringComparer.Ordinal)
.ThenBy(source => source.Sha256, StringComparer.Ordinal)
.Select(source => $"{source.Kind}\t{source.Sha256}")
.ToArray();
if (canonical.Length == 0)
return string.Empty;
var bytes = Encoding.UTF8.GetBytes(string.Join("\n", canonical));
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}

public static bool SameSclContentSet(
IEnumerable<IoFatSourceDescriptor> left,
IEnumerable<IoFatSourceDescriptor> right)
{
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);

var leftSources = left.Select(Normalize).ToArray();
var rightSources = right.Select(Normalize).ToArray();
if (leftSources.Length == 0 || rightSources.Length == 0 ||
leftSources.Length != rightSources.Length ||
leftSources.Any(source => !source.Kind.Equals(IoFatSourceKinds.Scl, StringComparison.OrdinalIgnoreCase)) ||
rightSources.Any(source => !source.Kind.Equals(IoFatSourceKinds.Scl, StringComparison.OrdinalIgnoreCase)))
{
return false;
}

return ComputeContentFingerprint(leftSources)
.Equals(ComputeContentFingerprint(rightSources), StringComparison.OrdinalIgnoreCase);
}

public static bool CompatibleContinuationSchema(string? savedSchema, string? currentSchema)
{
var saved = (savedSchema ?? string.Empty).Trim();
var current = (currentSchema ?? string.Empty).Trim();
if (saved.Equals(current, StringComparison.OrdinalIgnoreCase))
return true;

return TryGetSclSchemaMajor(saved, out var savedMajor) &&
TryGetSclSchemaMajor(current, out var currentMajor) &&
savedMajor == currentMajor;
}

public static string ProjectStorageFingerprint(IoTestProject project)
{
ArgumentNullException.ThrowIfNull(project);
Expand Down Expand Up @@ -204,6 +261,23 @@ private static string NormalizeHash(string sha256)
return normalized;
}

private static bool TryGetSclSchemaMajor(string schema, out int major)
{
major = 0;
const string prefix = "ARSAS-FAT-SCL-";
if (!schema.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return false;

var version = schema[prefix.Length..].Trim();
var separator = version.IndexOf('.');
var majorText = separator >= 0 ? version[..separator] : version;
return int.TryParse(
majorText,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out major);
}

private static string SafeFileName(string? value, string fallback)
{
var name = Path.GetFileName(string.IsNullOrWhiteSpace(value) ? fallback : value.Trim());
Expand Down
53 changes: 53 additions & 0 deletions RcbExportFilterWindow.VirtualizedSelectionAuthority.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using ArIED61850Tester.Models;

namespace ArIED61850Tester;

/// <summary>
/// Keeps the P1 multi-RCB selection model authoritative when the legacy DataGrid virtualizes
/// and recycles checkbox containers. A recycled CheckBox re-applies IsChecked from the bound
/// RcbExportRow.IsSelected value, which raises Checked/Unchecked even though the operator did
/// not change selection. The original single-select instance handlers interpret that visual
/// rehydration as a user action and call SelectOnly, silently clearing the other selected RCBs.
///
/// P1 pointer/keyboard handling already owns real operator selection changes, so these routed
/// state-change events are presentation-only for this grid and must not reach the stale
/// single-select handlers.
/// </summary>
public partial class RcbExportFilterWindow
{
[ModuleInitializer]
internal static void RegisterLegacyRcbVirtualizedSelectionAuthority()
{
EventManager.RegisterClassHandler(
typeof(CheckBox),
ToggleButton.CheckedEvent,
new RoutedEventHandler(SuppressLegacyRcbSingleSelectStateHandler),
handledEventsToo: true);
EventManager.RegisterClassHandler(
typeof(CheckBox),
ToggleButton.UncheckedEvent,
new RoutedEventHandler(SuppressLegacyRcbSingleSelectStateHandler),
handledEventsToo: true);
}

private static void SuppressLegacyRcbSingleSelectStateHandler(object sender, RoutedEventArgs e)
{
if (sender is not CheckBox checkBox ||
checkBox.DataContext is not RcbExportRow ||
Window.GetWindow(checkBox) is not RcbExportFilterWindow window ||
FindP1LegacyRcbAncestor<DataGrid>(checkBox) is not { } grid ||
!ReferenceEquals(grid, window.RcbGrid))
{
return;
}

// Do not allow the original XAML Checked/Unchecked instance handlers to translate
// DataGrid container materialization/recycling into a single-selection mutation.
// RcbExportRow.IsSelected remains the durable selection authority.
e.Handled = true;
}
}
Loading
Loading