From 7341e607081b5a6cb31b46e7261b44b0eee3172f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 21:30:47 +0700 Subject: [PATCH 001/158] FAT: add canonical legacy evidence migration --- .../IoFatCanonicalEvidenceMigrationService.cs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs diff --git a/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs b/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs new file mode 100644 index 000000000..1d2e13eb0 --- /dev/null +++ b/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs @@ -0,0 +1,134 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Canonicalizes the automatic Engineering -> FAT static DataSet workspace. +/// Historical scl-manual-* rows are evidence/provenance input only: they must never +/// survive as a second active FAT row beside the authoritative static DataSet member. +/// +public static class IoFatCanonicalEvidenceMigrationService +{ + public sealed record Result( + int RemovedManualRows, + int MigratedEvidenceRows, + int AmbiguousEvidenceRows); + + public static Result MigrateAndRemoveLegacyManualRows(IoTestProject project) + { + ArgumentNullException.ThrowIfNull(project); + + var removed = 0; + var migrated = 0; + var ambiguous = 0; + + foreach (var ied in project.Ieds) + { + if (!ied.TestPoints.Any(IoTestSignalSelectionService.IsSclDataSetAuthority)) + continue; + + var manualRows = ied.TestPoints + .Where(IsLegacyManualWorkspaceRow) + .ToArray(); + + foreach (var manual in manualRows) + { + var runtimeReference = FirstNonEmpty( + manual.LiveSignalReference, + manual.ObjectReference, + manual.EventLogSearchReference, + manual.SourceIecReference, + manual.ReportDisplayReference); + + var canonicalMatches = IoFatEngineeringSelectionBridge + .FindStaticDataSetRuntimeCoverage( + ied, + runtimeReference, + manual.FunctionalConstraint) + .ToArray(); + + if (canonicalMatches.Length == 1) + { + if (MigrateEvidenceOnly(manual, canonicalMatches[0])) + migrated++; + } + else if (canonicalMatches.Length > 1 && HasEvidence(manual)) + { + // A legacy scalar can cover more than one distinct static membership. + // Never guess which canonical row owns old evidence. Keep the source + // snapshot as audit history and require fresh evidence for those rows. + ambiguous++; + } + + // Removal is intentional even when evidence cannot be mapped uniquely. + // The automatic Engineering FAT path is a static DataSet projection; a + // historical manual alias is not a second IEC/SCL row authority. + if (ied.TestPoints.Remove(manual)) + removed++; + } + } + + return new Result(removed, migrated, ambiguous); + } + + public static bool IsLegacyManualWorkspaceRow(IoTestPointPlan point) + { + ArgumentNullException.ThrowIfNull(point); + return point.TestPointId.StartsWith("scl-manual-", StringComparison.OrdinalIgnoreCase) || + IoTestSignalSelectionService.IsSclWorkspaceAuthority(point); + } + + private static bool MigrateEvidenceOnly(IoTestPointPlan source, IoTestPointPlan target) + { + var changed = false; + + if (target.Runtime.Value1Evidence is null && source.Runtime.Value1Evidence is not null) + { + target.Runtime.Value1Evidence = source.Runtime.Value1Evidence; + changed = true; + } + + if (target.Runtime.Value2Evidence is null && source.Runtime.Value2Evidence is not null) + { + target.Runtime.Value2Evidence = source.Runtime.Value2Evidence; + changed = true; + } + + if (target.Runtime.OnEvidence is null && source.Runtime.OnEvidence is not null) + { + target.Runtime.OnEvidence = source.Runtime.OnEvidence; + changed = true; + } + + if (target.Runtime.OffEvidence is null && source.Runtime.OffEvidence is not null) + { + target.Runtime.OffEvidence = source.Runtime.OffEvidence; + changed = true; + } + + if (!target.Runtime.IsComplete && source.Runtime.IsComplete) + { + target.Runtime.State = source.Runtime.State; + target.Runtime.StatusReason = source.Runtime.StatusReason; + changed = true; + } + + if (source.Runtime.Attempt > target.Runtime.Attempt) + { + target.Runtime.Attempt = source.Runtime.Attempt; + changed = true; + } + + return changed; + } + + private static bool HasEvidence(IoTestPointPlan point) + => point.Runtime.Value1Evidence is not null || + point.Runtime.Value2Evidence is not null || + point.Runtime.OnEvidence is not null || + point.Runtime.OffEvidence is not null || + point.Runtime.IsComplete; + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; +} From 7560f65b4b5ecf31e61e7b9fe4627861062a478a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 21:31:20 +0700 Subject: [PATCH 002/158] FAT: enforce canonical static rows before production host --- ...indow.ProductionFatEngineeringBootstrap.cs | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/MainWindow.ProductionFatEngineeringBootstrap.cs b/MainWindow.ProductionFatEngineeringBootstrap.cs index 28ef4686d..1c0175339 100644 --- a/MainWindow.ProductionFatEngineeringBootstrap.cs +++ b/MainWindow.ProductionFatEngineeringBootstrap.cs @@ -130,6 +130,7 @@ private async Task EnsureProductionFatFromEngineeringAsync() engineeringDevices, token); token.ThrowIfCancellationRequested(); + var canonicalStaticRowCount = projection.Project.SignalCount; // Register the exact same ARIEC workspace instances already owned by Explorer. // Production FAT preparation can therefore prove shared SCL authority without @@ -150,20 +151,43 @@ private async Task EnsureProductionFatFromEngineeringAsync() SynchronizeImportedSclFatWithEngineering(launch.Project); - // This automatic entry path is explicitly Static DataSet FAT. Engineering may - // also expose selected scalar aliases outside the DataSet, and older saved P2 - // projects may contain scl-manual-* rows created from those aliases. Keep such - // rows/evidence in the project for audit continuity, but do not arm them in the - // shared workspace here. Otherwise a static member and its scalar alias can both - // resolve to the same live primary leaf and correctly trip session preflight. - var retiredManualRows = launch.Project.Ieds.Sum( - IoFatEngineeringSelectionBridge.RetireManualWorkspaceRowsForStaticDataSetMode); - if (retiredManualRows > 0) + // Canonical Engineering -> FAT rule: historical scl-manual-* rows are evidence + // input only. Snapshot restore may materialize them for legacy workflows, and + // shared-selection synchronization may encounter them, but the automatic static + // DataSet FAT surface must never expose a second row authority. Migrate evidence + // only when one legacy row maps uniquely to one static member, then physically + // remove every manual row before the production grid/session is exposed. + var migration = IoFatCanonicalEvidenceMigrationService + .MigrateAndRemoveLegacyManualRows(launch.Project); + + if (launch.Project.Ieds + .SelectMany(ied => ied.TestPoints) + .Any(IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow)) + { + throw new InvalidDataException( + "Canonical Engineering FAT still contains a legacy manual SCL row after migration."); + } + + if (launch.Project.SignalCount != canonicalStaticRowCount) + { + throw new InvalidDataException( + $"Canonical Engineering FAT expected {canonicalStaticRowCount} static DataSet row(s), but {launch.Project.SignalCount} row(s) remain after legacy migration."); + } + + if (migration.RemovedManualRows > 0) { AddLog( "INFO", "FAT", - $"Automatic Static DataSet scope retired {retiredManualRows} manual SCL workspace overlay(s); static membership remains authoritative."); + $"Canonical Engineering FAT removed {migration.RemovedManualRows} legacy manual row(s); migrated evidence for {migration.MigratedEvidenceRows} uniquely mapped row(s). Active rows remain the static DataSet authority only."); + } + + if (migration.AmbiguousEvidenceRows > 0) + { + AddLog( + "WARN", + "FAT", + $"{migration.AmbiguousEvidenceRows} legacy manual evidence row(s) matched multiple static DataSet memberships. ARSAS kept the canonical rows and did not guess an evidence owner; the persisted snapshot remains audit history."); } RegisterSharedSclSourcePaths(launch.Project, launch.Project.Ieds, projection.SourceInputs); @@ -179,7 +203,7 @@ private async Task EnsureProductionFatFromEngineeringAsync() launch.Workspace.ScheduleSave(); await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0); SynchronizeProductionFatSelectedIed(); - SetStatus($"FAT ready · {selected.Name} · Engineering static DataSet authority reused · no SCL re-import."); + SetStatus($"FAT ready · {selected.Name} · {canonicalStaticRowCount} canonical Engineering row(s) · no SCL re-import."); } catch (OperationCanceledException) { From e8254db3f581c799213f2384885a5b5db7e7bd53 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 21:32:04 +0700 Subject: [PATCH 003/158] FAT: regress canonical legacy row migration --- ...nonicalEvidenceMigrationRegressionTests.cs | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/ARSAS.Tests/IoFatCanonicalEvidenceMigrationRegressionTests.cs diff --git a/tests/ARSAS.Tests/IoFatCanonicalEvidenceMigrationRegressionTests.cs b/tests/ARSAS.Tests/IoFatCanonicalEvidenceMigrationRegressionTests.cs new file mode 100644 index 000000000..a372721ae --- /dev/null +++ b/tests/ARSAS.Tests/IoFatCanonicalEvidenceMigrationRegressionTests.cs @@ -0,0 +1,203 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatCanonicalEvidenceMigrationRegressionTests +{ + [Fact] + public void EngineeringStaticDataSetMigration_RemovesLegacyManualRowAndKeepsUniqueEvidence() + { + const string runtime = "AA1E1F06R4V1T3p1_OperationalValues/RPRE_MMXU1.A.phsA.cVal.mag.f"; + var canonical = StaticPoint("scl-static-1", runtime); + var manual = ManualPoint("scl-manual-7496d038be4fdc18e340", runtime); + Bind(canonical, runtime); + Bind(manual, runtime); + + var value1 = Evidence(FatValueSlot.Value1, "10.1"); + var value2 = Evidence(FatValueSlot.Value2, "12.7"); + manual.Runtime.Value1Evidence = value1; + manual.Runtime.Value2Evidence = value2; + manual.Runtime.State = IoTestPointState.Passed; + manual.Runtime.StatusReason = "legacy completed result"; + manual.Runtime.Attempt = 2; + + var project = Project(canonical, manual); + + var result = IoFatCanonicalEvidenceMigrationService.MigrateAndRemoveLegacyManualRows(project); + + Assert.Equal(1, result.RemovedManualRows); + Assert.Equal(1, result.MigratedEvidenceRows); + Assert.Equal(0, result.AmbiguousEvidenceRows); + Assert.Single(project.Ieds[0].TestPoints); + Assert.Same(canonical, project.Ieds[0].TestPoints[0]); + Assert.DoesNotContain(project.Ieds[0].TestPoints, IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow); + Assert.Same(value1, canonical.Runtime.Value1Evidence); + Assert.Same(value2, canonical.Runtime.Value2Evidence); + Assert.Equal(IoTestPointState.Passed, canonical.Runtime.State); + Assert.Equal(2, canonical.Runtime.Attempt); + } + + [Fact] + public void EngineeringStaticDataSetMigration_AmbiguousLegacyEvidenceNeverCreatesOrChoosesDuplicateAuthority() + { + const string runtime = "AA1E1F06R4LD0/GGIO1.AnIn1.mag.f"; + var canonicalA = StaticPoint("scl-static-a", runtime, "IED/LLN0.dsA"); + var canonicalB = StaticPoint("scl-static-b", runtime, "IED/LLN0.dsB"); + var manual = ManualPoint("scl-manual-aaaaaaaaaaaaaaaaaaaa", runtime); + Bind(canonicalA, runtime); + Bind(canonicalB, runtime); + Bind(manual, runtime); + manual.Runtime.Value1Evidence = Evidence(FatValueSlot.Value1, "3.14"); + + var project = Project(canonicalA, canonicalB, manual); + + var result = IoFatCanonicalEvidenceMigrationService.MigrateAndRemoveLegacyManualRows(project); + + Assert.Equal(1, result.RemovedManualRows); + Assert.Equal(0, result.MigratedEvidenceRows); + Assert.Equal(1, result.AmbiguousEvidenceRows); + Assert.Equal(2, project.SignalCount); + Assert.All(project.Ieds[0].TestPoints, point => Assert.False(IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow(point))); + Assert.Null(canonicalA.Runtime.Value1Evidence); + Assert.Null(canonicalB.Runtime.Value1Evidence); + } + + [Fact] + public void ManualOnlyLegacyProject_IsNotCanonicalizedByStaticDataSetMigration() + { + const string runtime = "AA1E1F06R4LD0/GGIO1.Ind1.stVal"; + var manual = ManualPoint("scl-manual-bbbbbbbbbbbbbbbbbbbb", runtime); + var project = Project(manual); + + var result = IoFatCanonicalEvidenceMigrationService.MigrateAndRemoveLegacyManualRows(project); + + Assert.Equal(0, result.RemovedManualRows); + Assert.Single(project.Ieds[0].TestPoints); + Assert.Same(manual, project.Ieds[0].TestPoints[0]); + } + + [Fact] + public void EngineeringBootstrap_EnforcesCanonicalRowCountBeforeProductionGridIsShown() + { + var source = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); + var synchronize = source.IndexOf("SynchronizeImportedSclFatWithEngineering(launch.Project);", StringComparison.Ordinal); + var migrate = source.IndexOf("MigrateAndRemoveLegacyManualRows(launch.Project)", StringComparison.Ordinal); + var invariant = source.IndexOf("launch.Project.SignalCount != canonicalStaticRowCount", StringComparison.Ordinal); + var show = source.IndexOf("await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0);", StringComparison.Ordinal); + + Assert.True(synchronize >= 0); + Assert.True(migrate > synchronize, "Legacy evidence migration must run after Engineering synchronization."); + Assert.True(invariant > migrate, "Canonical row-count invariant must run after legacy rows are removed."); + Assert.True(show > invariant, "The production FAT grid must not be exposed before canonical row-count validation."); + } + + private static IoTestProject Project(params IoTestPointPlan[] points) + => new() + { + ProjectId = "canonical-regression", + SchemaVersion = "ARSAS-FAT-SCL-1.0", + ProjectName = "Canonical regression", + Ieds = new List + { + new() + { + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + TestPoints = points.ToList() + } + } + }; + + private static IoTestPointPlan StaticPoint( + string id, + string runtimeReference, + string dataSet = "AA1E1F06R4LD0/LLN0.OperationalValues") + => new() + { + TestPointId = id, + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + SignalName = "Static member", + ObjectReference = runtimeReference, + FunctionalConstraint = "MX", + ExpectedOnText = "Value 1", + ExpectedOffText = "Value 2", + DataType = "FLOAT32", + SignalAddress = "source-sha", + DataSetName = dataSet, + SourceIecReference = runtimeReference, + ReportDisplayReference = runtimeReference, + EventLogSearchReference = runtimeReference, + SignalKind = FatSignalKind.Analog, + CaptureMode = FatCaptureMode.OperatorSnapshot, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus, + BindingEvidence = "Static SCL DataSet authority" + }; + + private static IoTestPointPlan ManualPoint(string id, string runtimeReference) + => new() + { + TestPointId = id, + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + SignalName = "Legacy manual alias", + ObjectReference = runtimeReference, + FunctionalConstraint = "MX", + ExpectedOnText = "Value 1", + ExpectedOffText = "Value 2", + DataType = "FLOAT32", + SignalAddress = "source-sha", + SourceIecReference = runtimeReference, + ReportDisplayReference = runtimeReference, + EventLogSearchReference = runtimeReference, + SignalKind = FatSignalKind.Analog, + CaptureMode = FatCaptureMode.OperatorSnapshot, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus, + BindingEvidence = "Shared SCL workspace authority" + }; + + private static void Bind(IoTestPointPlan point, string runtimeReference) + => point.ApplyLiveBinding( + IoTestLiveBindingState.LivePointReady, + "field-proven primary leaf", + "device-1", + runtimeReference); + + private static FatValueEvidence Evidence(FatValueSlot slot, string raw) + => new( + Guid.NewGuid(), + slot, + FatEvidenceCaptureKind.OperatorSnapshot, + raw, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + "GOOD", + "regression", + 1, + 1); + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + 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}'."); + } +} From 2cc9495651de6eb362e3b0a6ec81a0ab894d12ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 21:32:44 +0700 Subject: [PATCH 004/158] FAT: keep legacy selection cleanup as canonical guard --- MainWindow.ProductionFatEngineeringBootstrap.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/MainWindow.ProductionFatEngineeringBootstrap.cs b/MainWindow.ProductionFatEngineeringBootstrap.cs index 1c0175339..3e481ee9b 100644 --- a/MainWindow.ProductionFatEngineeringBootstrap.cs +++ b/MainWindow.ProductionFatEngineeringBootstrap.cs @@ -160,6 +160,17 @@ private async Task EnsureProductionFatFromEngineeringAsync() var migration = IoFatCanonicalEvidenceMigrationService .MigrateAndRemoveLegacyManualRows(launch.Project); + // Keep the former selection-only cleanup as an idempotence assertion, not as the + // duplicate fix. Canonical migration above must already have physically removed + // every manual row; if this changes anything, a second authority escaped. + var retiredManualRows = launch.Project.Ieds.Sum( + IoFatEngineeringSelectionBridge.RetireManualWorkspaceRowsForStaticDataSetMode); + if (retiredManualRows != 0) + { + throw new InvalidDataException( + $"Canonical Engineering FAT left {retiredManualRows} manual selection overlay(s) after physical migration."); + } + if (launch.Project.Ieds .SelectMany(ied => ied.TestPoints) .Any(IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow)) From f3657cd9e612562c1abc39e92dfc20321a8a085e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 22:10:56 +0700 Subject: [PATCH 005/158] Fix field FAT canonical authority normalization --- .../IoFatCanonicalEvidenceMigrationService.cs | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs b/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs index 1d2e13eb0..c98a04b15 100644 --- a/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs +++ b/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs @@ -9,6 +9,8 @@ namespace ArIED61850Tester.Services.IoTesting; /// public static class IoFatCanonicalEvidenceMigrationService { + private const string EngineeringProjectionStaticDataSetAuthority = "ENGINEERING_SCL_DATASET_AUTHORITY"; + public sealed record Result( int RemovedManualRows, int MigratedEvidenceRows, @@ -24,9 +26,26 @@ public static Result MigrateAndRemoveLegacyManualRows(IoTestProject project) foreach (var ied in project.Ieds) { - if (!ied.TestPoints.Any(IoTestSignalSelectionService.IsSclDataSetAuthority)) + // Engineering projection deliberately carries a provenance-specific binding + // status before bootstrap. Normalize that status to the shared static DataSet + // authority contract before any legacy migration or runtime matching. Without + // this step, field projects can contain 58 canonical Engineering rows plus + // restored scl-manual-* history, while the migration incorrectly concludes + // there is no static authority and skips the IED entirely. + var canonicalRows = ied.TestPoints + .Where(IsCanonicalStaticDataSetAuthority) + .ToArray(); + if (canonicalRows.Length == 0) continue; + foreach (var canonical in canonicalRows) + { + if (IsEngineeringProjectionStaticDataSetAuthority(canonical)) + { + canonical.BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus; + } + } + var manualRows = ied.TestPoints .Where(IsLegacyManualWorkspaceRow) .ToArray(); @@ -78,6 +97,16 @@ public static bool IsLegacyManualWorkspaceRow(IoTestPointPlan point) IoTestSignalSelectionService.IsSclWorkspaceAuthority(point); } + private static bool IsCanonicalStaticDataSetAuthority(IoTestPointPlan point) + => IoTestSignalSelectionService.IsSclDataSetAuthority(point) || + IsEngineeringProjectionStaticDataSetAuthority(point); + + private static bool IsEngineeringProjectionStaticDataSetAuthority(IoTestPointPlan point) + => string.Equals( + point.BindingStatus, + EngineeringProjectionStaticDataSetAuthority, + StringComparison.OrdinalIgnoreCase); + private static bool MigrateEvidenceOnly(IoTestPointPlan source, IoTestPointPlan target) { var changed = false; From 169c5306dc75e614d7552339e96540af60befcfe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 22:11:10 +0700 Subject: [PATCH 006/158] Add regression for Engineering authority field failure --- ...ngAuthorityNormalizationRegressionTests.cs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs diff --git a/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs b/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs new file mode 100644 index 000000000..0caf639ca --- /dev/null +++ b/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs @@ -0,0 +1,95 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatEngineeringAuthorityNormalizationRegressionTests +{ + [Fact] + public void EngineeringProjectionAuthority_IsNormalizedBeforeLegacyManualMigration() + { + const string runtime = "AA1E1F06R4V1T3p1_OperationalValues/RPRE_MMXU1.A.phsA.cVal.mag.f"; + var canonical = new IoTestPointPlan + { + TestPointId = "scl-source-static-1", + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + SignalName = "Engineering static member", + ObjectReference = runtime, + FunctionalConstraint = "MX", + DataSetName = "AA1E1F06R4LD0/LLN0.OperationalValues", + SourceIecReference = runtime, + ReportDisplayReference = runtime, + EventLogSearchReference = runtime, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = "ENGINEERING_SCL_DATASET_AUTHORITY" + }; + canonical.ApplyLiveBinding( + IoTestLiveBindingState.LivePointReady, + "Engineering live point", + "device-1", + runtime); + + var manual = new IoTestPointPlan + { + TestPointId = "scl-manual-7496d038be4fdc18e340", + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + SignalName = "Restored legacy alias", + ObjectReference = runtime, + FunctionalConstraint = "MX", + SourceIecReference = runtime, + ReportDisplayReference = runtime, + EventLogSearchReference = runtime, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus + }; + manual.ApplyLiveBinding( + IoTestLiveBindingState.LivePointReady, + "Legacy live alias", + "device-1", + runtime); + manual.Runtime.Value1Evidence = new FatValueEvidence( + Guid.NewGuid(), + FatValueSlot.Value1, + FatEvidenceCaptureKind.OperatorSnapshot, + "10.1", + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + "GOOD", + "field regression", + 1, + 1); + + var project = new IoTestProject + { + ProjectId = "field-authority-regression", + SchemaVersion = "ARSAS-FAT-SCL-1.0", + ProjectName = "Field authority regression", + Ieds = new List + { + new() + { + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + TestPoints = new List { canonical, manual } + } + } + }; + + var result = IoFatCanonicalEvidenceMigrationService.MigrateAndRemoveLegacyManualRows(project); + + Assert.Equal(1, result.RemovedManualRows); + Assert.Equal(1, result.MigratedEvidenceRows); + Assert.Single(project.Ieds[0].TestPoints); + Assert.Same(canonical, project.Ieds[0].TestPoints[0]); + Assert.Equal(IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus, canonical.BindingStatus); + Assert.True(IoTestSignalSelectionService.IsSclDataSetAuthority(canonical)); + Assert.NotNull(canonical.Runtime.Value1Evidence); + Assert.DoesNotContain(project.Ieds[0].TestPoints, IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow); + } +} From fd65abe6b66dd83a1f5f30d97290f15a41eebf0c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 22:14:40 +0700 Subject: [PATCH 007/158] Keep Engineering FAT authority immutable during migration --- .../IoFatCanonicalEvidenceMigrationService.cs | 35 +++---------------- 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs b/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs index c98a04b15..4a952f217 100644 --- a/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs +++ b/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs @@ -9,8 +9,6 @@ namespace ArIED61850Tester.Services.IoTesting; /// public static class IoFatCanonicalEvidenceMigrationService { - private const string EngineeringProjectionStaticDataSetAuthority = "ENGINEERING_SCL_DATASET_AUTHORITY"; - public sealed record Result( int RemovedManualRows, int MigratedEvidenceRows, @@ -26,26 +24,13 @@ public static Result MigrateAndRemoveLegacyManualRows(IoTestProject project) foreach (var ied in project.Ieds) { - // Engineering projection deliberately carries a provenance-specific binding - // status before bootstrap. Normalize that status to the shared static DataSet - // authority contract before any legacy migration or runtime matching. Without - // this step, field projects can contain 58 canonical Engineering rows plus - // restored scl-manual-* history, while the migration incorrectly concludes - // there is no static authority and skips the IED entirely. - var canonicalRows = ied.TestPoints - .Where(IsCanonicalStaticDataSetAuthority) - .ToArray(); - if (canonicalRows.Length == 0) + // The Engineering projection carries a provenance-specific immutable binding + // status. IoTestSignalSelectionService recognizes it as the same static DataSet + // authority as direct SCL imports. This check therefore covers the real field + // case: canonical Engineering rows plus restored scl-manual-* snapshot history. + if (!ied.TestPoints.Any(IoTestSignalSelectionService.IsSclDataSetAuthority)) continue; - foreach (var canonical in canonicalRows) - { - if (IsEngineeringProjectionStaticDataSetAuthority(canonical)) - { - canonical.BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus; - } - } - var manualRows = ied.TestPoints .Where(IsLegacyManualWorkspaceRow) .ToArray(); @@ -97,16 +82,6 @@ public static bool IsLegacyManualWorkspaceRow(IoTestPointPlan point) IoTestSignalSelectionService.IsSclWorkspaceAuthority(point); } - private static bool IsCanonicalStaticDataSetAuthority(IoTestPointPlan point) - => IoTestSignalSelectionService.IsSclDataSetAuthority(point) || - IsEngineeringProjectionStaticDataSetAuthority(point); - - private static bool IsEngineeringProjectionStaticDataSetAuthority(IoTestPointPlan point) - => string.Equals( - point.BindingStatus, - EngineeringProjectionStaticDataSetAuthority, - StringComparison.OrdinalIgnoreCase); - private static bool MigrateEvidenceOnly(IoTestPointPlan source, IoTestPointPlan target) { var changed = false; From cefe09e4ff0a4dc8385059407b86167e203eb7b9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 22:15:27 +0700 Subject: [PATCH 008/158] Recognize Engineering projection as static DataSet authority --- Services/IoTesting/IoTestSignalSelectionService.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Services/IoTesting/IoTestSignalSelectionService.cs b/Services/IoTesting/IoTestSignalSelectionService.cs index 22a47c7b0..2f905c0bd 100644 --- a/Services/IoTesting/IoTestSignalSelectionService.cs +++ b/Services/IoTesting/IoTestSignalSelectionService.cs @@ -31,6 +31,7 @@ public sealed record IoTestSignalSelectionResult( public sealed class IoTestSignalSelectionService { internal const string SclDataSetAuthorityBindingStatus = "SCL_DATASET_AUTHORITY"; + internal const string EngineeringSclDataSetAuthorityBindingStatus = "ENGINEERING_SCL_DATASET_AUTHORITY"; internal const string SclWorkspaceAuthorityBindingStatus = "SCL_WORKSPACE_AUTHORITY"; private const int SclStaticMembershipIdentityBonus = 1000; @@ -245,9 +246,13 @@ private static bool TryResolveAlreadyLiveExactScope( internal static bool IsSclDataSetAuthority(IoTestPointPlan point) => string.Equals( - point.BindingStatus, - SclDataSetAuthorityBindingStatus, - StringComparison.OrdinalIgnoreCase); + point.BindingStatus, + SclDataSetAuthorityBindingStatus, + StringComparison.OrdinalIgnoreCase) || + string.Equals( + point.BindingStatus, + EngineeringSclDataSetAuthorityBindingStatus, + StringComparison.OrdinalIgnoreCase); internal static bool IsSclWorkspaceAuthority(IoTestPointPlan point) => string.Equals( From 5ba59bdcd9d8fd097b94ef2ca2604724adff0d33 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 22:15:44 +0700 Subject: [PATCH 009/158] Assert immutable Engineering DataSet authority field contract --- ...ngineeringAuthorityNormalizationRegressionTests.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs b/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs index 0caf639ca..2ac0b4ee5 100644 --- a/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs @@ -6,7 +6,7 @@ namespace ARSAS.Tests; public sealed class IoFatEngineeringAuthorityNormalizationRegressionTests { [Fact] - public void EngineeringProjectionAuthority_IsNormalizedBeforeLegacyManualMigration() + public void EngineeringProjectionAuthority_IsRecognizedBeforeLegacyManualMigration() { const string runtime = "AA1E1F06R4V1T3p1_OperationalValues/RPRE_MMXU1.A.phsA.cVal.mag.f"; var canonical = new IoTestPointPlan @@ -24,7 +24,7 @@ public void EngineeringProjectionAuthority_IsNormalizedBeforeLegacyManualMigrati WorkspaceSelected = true, TestEnabled = true, ImportReady = true, - BindingStatus = "ENGINEERING_SCL_DATASET_AUTHORITY" + BindingStatus = IoTestSignalSelectionService.EngineeringSclDataSetAuthorityBindingStatus }; canonical.ApplyLiveBinding( IoTestLiveBindingState.LivePointReady, @@ -81,13 +81,18 @@ public void EngineeringProjectionAuthority_IsNormalizedBeforeLegacyManualMigrati } }; + // Critical field contract: Engineering projection rows must already be recognized + // as static DataSet authority before synchronize/migration runs. BindingStatus is + // immutable provenance and must not be rewritten later. + Assert.True(IoTestSignalSelectionService.IsSclDataSetAuthority(canonical)); + var result = IoFatCanonicalEvidenceMigrationService.MigrateAndRemoveLegacyManualRows(project); Assert.Equal(1, result.RemovedManualRows); Assert.Equal(1, result.MigratedEvidenceRows); Assert.Single(project.Ieds[0].TestPoints); Assert.Same(canonical, project.Ieds[0].TestPoints[0]); - Assert.Equal(IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus, canonical.BindingStatus); + Assert.Equal(IoTestSignalSelectionService.EngineeringSclDataSetAuthorityBindingStatus, canonical.BindingStatus); Assert.True(IoTestSignalSelectionService.IsSclDataSetAuthority(canonical)); Assert.NotNull(canonical.Runtime.Value1Evidence); Assert.DoesNotContain(project.Ieds[0].TestPoints, IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow); From 4a02d82a3eecaab8b28c12b597a65f1f4c64a529 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 22:18:44 +0700 Subject: [PATCH 010/158] Fix required fields in Engineering authority regression fixture --- .../IoFatEngineeringAuthorityNormalizationRegressionTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs b/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs index 2ac0b4ee5..e73742414 100644 --- a/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs @@ -21,6 +21,8 @@ public void EngineeringProjectionAuthority_IsRecognizedBeforeLegacyManualMigrati SourceIecReference = runtime, ReportDisplayReference = runtime, EventLogSearchReference = runtime, + ExpectedOnText = "ON", + ExpectedOffText = "OFF", WorkspaceSelected = true, TestEnabled = true, ImportReady = true, @@ -43,6 +45,8 @@ public void EngineeringProjectionAuthority_IsRecognizedBeforeLegacyManualMigrati SourceIecReference = runtime, ReportDisplayReference = runtime, EventLogSearchReference = runtime, + ExpectedOnText = "ON", + ExpectedOffText = "OFF", WorkspaceSelected = true, TestEnabled = true, ImportReady = true, From eac060bf46839d8f546bc7d8ed8a5d6e21a41b0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 05:29:26 +0700 Subject: [PATCH 011/158] FAT P1A: stop legacy bootstrap on Engineering tab entry --- ...Window.ProductionFatEngineeringBootstrap.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/MainWindow.ProductionFatEngineeringBootstrap.cs b/MainWindow.ProductionFatEngineeringBootstrap.cs index 3e481ee9b..c1785d261 100644 --- a/MainWindow.ProductionFatEngineeringBootstrap.cs +++ b/MainWindow.ProductionFatEngineeringBootstrap.cs @@ -61,15 +61,25 @@ private void ProductionFatEngineeringBootstrap_PropertyChanged(object? sender, P private void QueueProductionFatEngineeringBootstrap() { - // FAT preparation must never run in the background while Engineering is connecting - // or monitoring. The shared Engineering acquisition session remains authoritative; - // clicking FAT is the only navigation event allowed to build the FAT projection. + // P1A boundary: entering FAT from Engineering is navigation only. The Engineering + // workspace already owns the canonical static DataSet rows and acquisition session; + // never schedule the legacy IoTest projection/bootstrap from this navigation path. if (!_productionFatEngineeringBootstrapInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) return; + // If an older request is still preparing while the operator enters FAT, make it stale + // immediately. Do not start BuildAsync/OpenDescribedSources/ShowIoTestingWorkspace here. + _productionFatEngineeringBootstrapCts?.Cancel(); + Dispatcher.BeginInvoke( DispatcherPriority.ContextIdle, - new Action(async () => await EnsureProductionFatFromEngineeringAsync())); + new Action(() => + { + if (MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + SynchronizeProductionFatSelectedIed(); + })); } private async Task EnsureProductionFatFromEngineeringAsync() From f6ca0bff427a93e11e56fc0074d54def523dfab4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 05:29:47 +0700 Subject: [PATCH 012/158] Test P1A Engineering to FAT navigation boundary --- ...ductionFatEngineeringTabRegressionTests.cs | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs index 633df0122..868084656 100644 --- a/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs @@ -17,21 +17,23 @@ public void EngineeringProjection_ReusesParsedSclWorkspaceWithoutOpeningXmlAgain } [Fact] - public void ProductionFatTab_AutoBootstrapsFromSelectedEngineeringStaticDataSet() + public void ProductionFatTab_EntryReusesExistingHostWithoutLegacyProjectionBootstrap() { var source = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatEngineeringBootstrap.cs")); - - Assert.Contains("QueueProductionFatEngineeringBootstrap();", source, StringComparison.Ordinal); - Assert.Contains("selected?.SclWorkspace", source, StringComparison.Ordinal); - Assert.Contains("DesignModel.DataSets.Sum", source, StringComparison.Ordinal); - Assert.Contains("IoFatEngineeringWorkspaceProjectionService.BuildAsync", source, StringComparison.Ordinal); - Assert.Contains("AdoptEngineeringRuntimeWorkspaces", source, StringComparison.Ordinal); - Assert.Contains("IoTestWorkspaceBootstrapService.OpenDescribedSourcesAsync", source, StringComparison.Ordinal); - Assert.Contains("projection.DescribedSources", source, StringComparison.Ordinal); - Assert.Contains("SynchronizeImportedSclFatWithEngineering", source, StringComparison.Ordinal); - Assert.Contains("ShowIoTestingWorkspaceAsync", source, StringComparison.Ordinal); - Assert.Contains("no SCL re-import", source, StringComparison.Ordinal); - Assert.DoesNotContain("OpenSclFatTesting_Click", source, StringComparison.Ordinal); + var queueStart = source.IndexOf("private void QueueProductionFatEngineeringBootstrap()", StringComparison.Ordinal); + var legacyStart = source.IndexOf("private async Task EnsureProductionFatFromEngineeringAsync()", queueStart, StringComparison.Ordinal); + + Assert.True(queueStart >= 0, "P1A requires one explicit Engineering -> FAT navigation gateway."); + Assert.True(legacyStart > queueStart, "Legacy bootstrap may remain isolated, but it must not own FAT navigation."); + + var queue = source[queueStart..legacyStart]; + Assert.Contains("_productionFatEngineeringBootstrapCts?.Cancel();", queue, StringComparison.Ordinal); + Assert.Contains("SynchronizeProductionFatSelectedIed();", queue, StringComparison.Ordinal); + Assert.DoesNotContain("EnsureProductionFatFromEngineeringAsync", queue, StringComparison.Ordinal); + Assert.DoesNotContain("IoFatEngineeringWorkspaceProjectionService.BuildAsync", queue, StringComparison.Ordinal); + Assert.DoesNotContain("OpenDescribedSourcesAsync", queue, StringComparison.Ordinal); + Assert.DoesNotContain("ShowIoTestingWorkspaceAsync", queue, StringComparison.Ordinal); + Assert.DoesNotContain("OpenSclFatTesting_Click", queue, StringComparison.Ordinal); } [Fact] From 4f463bbfca3df3e52c18b0a6e8f845e2389322ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 05:57:18 +0700 Subject: [PATCH 013/158] Add P1B sparse canonical FAT evidence overlay --- .../NativeFatCanonicalEvidenceOverlay.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs diff --git a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs new file mode 100644 index 000000000..c6b1ebd3f --- /dev/null +++ b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs @@ -0,0 +1,100 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +public enum NativeFatEvidenceField +{ + Value1, + Value2, + Result +} + +/// +/// Sparse FAT-only evidence keyed by the canonical Engineering live-row identity. +/// It deliberately never owns or clones IEC 61850 rows: the row object remains +/// Iec61850MonitorPoint and this service stores only operator evidence. +/// +public static class NativeFatCanonicalEvidenceOverlay +{ + public static string BuildRowKey(Iec61850MonitorPoint point) + { + ArgumentNullException.ThrowIfNull(point); + + // Engineering already defines PointKey as DeviceId + normalized IEC reference. + // Reuse that identity verbatim so FAT cannot invent a second semantic key space. + if (!string.IsNullOrWhiteSpace(point.IecReference)) + return point.PointKey; + + // Defensive fallback for non-canonical/manual monitor rows. Automatic static + // DataSet FAT is expected to take the PointKey path above. + if (!string.IsNullOrWhiteSpace(point.IecTelegram)) + return $"{point.DeviceId}|{point.IecTelegram.Trim()}"; + + return $"{point.DeviceId}|{point.SignalName.Trim()}|{point.IecDataType.Trim()}"; + } + + public static string Read( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + + if (!cache.EvidenceByRow.TryGetValue(BuildRowKey(point), out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => slot.Value1, + NativeFatEvidenceField.Value2 => slot.Value2, + NativeFatEvidenceField.Result => slot.Result, + _ => string.Empty + }; + } + + public static void Write( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field, + string? value) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + + var key = BuildRowKey(point); + var text = value ?? string.Empty; + + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + { + // Reading/clearing an untouched cell must not allocate evidence. + if (string.IsNullOrWhiteSpace(text)) + return; + + slot = new NativeFatEvidenceSlotState(); + cache.EvidenceByRow[key] = slot; + } + + switch (field) + { + case NativeFatEvidenceField.Value1: + slot.Value1 = text; + break; + case NativeFatEvidenceField.Value2: + slot.Value2 = text; + break; + case NativeFatEvidenceField.Result: + slot.Result = text; + break; + } + + // Keep the overlay genuinely sparse. Clearing the last evidence value removes + // the entry rather than leaving a shadow row behind. + if (string.IsNullOrWhiteSpace(slot.Value1) && + string.IsNullOrWhiteSpace(slot.Value2) && + string.IsNullOrWhiteSpace(slot.Result)) + { + cache.EvidenceByRow.Remove(key); + } + } +} From 1a3ba25c2dc4450cb9c36f1e56658ed0efc84f1c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 05:57:54 +0700 Subject: [PATCH 014/158] Finish P1A native FAT grid and wire P1B evidence overlay --- MainWindow.NativeFatCanonicalGrid.cs | 294 +++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 MainWindow.NativeFatCanonicalGrid.cs diff --git a/MainWindow.NativeFatCanonicalGrid.cs b/MainWindow.NativeFatCanonicalGrid.cs new file mode 100644 index 000000000..e5d3aa14e --- /dev/null +++ b/MainWindow.NativeFatCanonicalGrid.cs @@ -0,0 +1,294 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private readonly Dictionary _nativeFatSessionByIed = + new(StringComparer.OrdinalIgnoreCase); + + private DataGrid? _nativeFatCanonicalGrid; + private TextBlock? _nativeFatIedText; + private TextBlock? _nativeFatRowCountText; + private TextBlock? _nativeFatStatusText; + private string? _nativeFatBoundIedKey; + + /// + /// P1A: FAT renders the exact Engineering live-row objects. There is no projection, + /// SCL parse, IoTestPointPlan collection, or second acquisition owner in this surface. + /// P1B adds only three sparse evidence columns keyed outside those canonical rows. + /// + private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = null) + { + var root = new Grid + { + Margin = new Thickness(16) + }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(10) }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var header = new Border + { + Padding = new Thickness(14, 10, 14, 10), + CornerRadius = new CornerRadius(12), + Background = TryFindResource("CardBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("CardBorder") as Brush ?? new SolidColorBrush(Color.FromRgb(220, 228, 239)), + BorderThickness = new Thickness(1) + }; + + var headerGrid = new Grid(); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var titlePanel = new StackPanel(); + _nativeFatIedText = new TextBlock + { + Text = "FAT · select an Engineering IED", + FontSize = 16, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("Ink") as Brush ?? Brushes.Black + }; + _nativeFatStatusText = new TextBlock + { + Text = string.IsNullOrWhiteSpace(statusText) + ? "Canonical Engineering live rows · shared acquisition · sparse FAT evidence overlay" + : statusText, + Margin = new Thickness(0, 3, 0, 0), + FontSize = 10.8, + Foreground = TryFindResource("Muted") as Brush ?? Brushes.DimGray + }; + titlePanel.Children.Add(_nativeFatIedText); + titlePanel.Children.Add(_nativeFatStatusText); + headerGrid.Children.Add(titlePanel); + + _nativeFatRowCountText = new TextBlock + { + Text = "0 rows", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("Accent") as Brush ?? Brushes.RoyalBlue, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(16, 0, 0, 0) + }; + Grid.SetColumn(_nativeFatRowCountText, 1); + headerGrid.Children.Add(_nativeFatRowCountText); + header.Child = headerGrid; + root.Children.Add(header); + + _nativeFatCanonicalGrid = new DataGrid + { + AutoGenerateColumns = false, + CanUserAddRows = false, + CanUserDeleteRows = false, + CanUserReorderColumns = false, + CanUserResizeColumns = true, + IsReadOnly = false, + HeadersVisibility = DataGridHeadersVisibility.Column, + GridLinesVisibility = DataGridGridLinesVisibility.Horizontal, + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(Color.FromRgb(225, 231, 240)), + Background = Brushes.White, + RowBackground = Brushes.White, + SelectionMode = DataGridSelectionMode.Single, + SelectionUnit = DataGridSelectionUnit.FullRow, + RowHeaderWidth = 0, + FrozenColumnCount = 1, + EnableRowVirtualization = true, + EnableColumnVirtualization = true, + HorizontalGridLinesBrush = new SolidColorBrush(Color.FromRgb(232, 237, 245)), + VerticalGridLinesBrush = Brushes.Transparent + }; + VirtualizingPanel.SetIsVirtualizing(_nativeFatCanonicalGrid, true); + VirtualizingPanel.SetVirtualizationMode(_nativeFatCanonicalGrid, VirtualizationMode.Recycling); + ScrollViewer.SetCanContentScroll(_nativeFatCanonicalGrid, true); + ScrollViewer.SetHorizontalScrollBarVisibility(_nativeFatCanonicalGrid, ScrollBarVisibility.Auto); + ScrollViewer.SetVerticalScrollBarVisibility(_nativeFatCanonicalGrid, ScrollBarVisibility.Auto); + _nativeFatCanonicalGrid.CellEditEnding += NativeFatCanonicalGrid_CellEditEnding; + + AddCanonicalTextColumn("Status", nameof(Iec61850MonitorPoint.Status), 90); + AddCanonicalTextColumn("Type", nameof(Iec61850MonitorPoint.IecDataType), 84); + AddCanonicalTextColumn("Address", nameof(Iec61850MonitorPoint.IecTelegram), 210); + AddCanonicalTextColumn("Message", nameof(Iec61850MonitorPoint.SignalName), 180); + AddCanonicalTextColumn("Data Reference", nameof(Iec61850MonitorPoint.IecReference), 290); + AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 92); + AddCanonicalTextColumn("Timestamp", nameof(Iec61850MonitorPoint.DeviceTimestamp), 152); + AddCanonicalTextColumn("Value", nameof(Iec61850MonitorPoint.DisplayValue), 90); + _nativeFatCanonicalGrid.Columns.Add(new NativeFatEvidenceColumn(this, "Value 1", NativeFatEvidenceField.Value1, 104)); + _nativeFatCanonicalGrid.Columns.Add(new NativeFatEvidenceColumn(this, "Value 2", NativeFatEvidenceField.Value2, 104)); + _nativeFatCanonicalGrid.Columns.Add(new NativeFatEvidenceColumn(this, "Result", NativeFatEvidenceField.Result, 104)); + + Grid.SetRow(_nativeFatCanonicalGrid, 2); + root.Children.Add(_nativeFatCanonicalGrid); + return root; + } + + private void AddCanonicalTextColumn(string header, string path, double width) + { + if (_nativeFatCanonicalGrid == null) + return; + + _nativeFatCanonicalGrid.Columns.Add(new DataGridTextColumn + { + Header = header, + Binding = new Binding(path) { Mode = BindingMode.OneWay }, + Width = new DataGridLength(width), + IsReadOnly = true + }); + } + + private void BindNativeFatCanonicalRows() + { + if (_nativeFatCanonicalGrid == null || _productionFatWindow is { IsLoaded: true }) + return; + + // Finish any in-cell evidence edit against the previously bound IED before switching. + _nativeFatCanonicalGrid.CommitEdit(DataGridEditingUnit.Cell, true); + _nativeFatCanonicalGrid.CommitEdit(DataGridEditingUnit.Row, true); + SaveNativeFatSessionState(); + + var device = SelectedDevice; + _nativeFatBoundIedKey = device?.DeviceId; + + // P1A invariant: this is the exact same collection used by Engineering. + // No Select/ToList/projection/wrapper is allowed here. + _nativeFatCanonicalGrid.ItemsSource = device?.Points; + + _nativeFatIedText!.Text = device == null + ? "FAT · select an Engineering IED" + : $"FAT · {device.Name} · {device.IpAddress}:{device.Port}"; + _nativeFatRowCountText!.Text = device == null ? "0 rows" : $"{device.Points.Count} rows"; + _nativeFatStatusText!.Text = device == null + ? "Select an Engineering IED with canonical live rows." + : "Canonical Engineering live rows · no reconnect · sparse Value 1 / Value 2 / Result overlay"; + + RestoreNativeFatSessionState(device); + } + + private void SaveNativeFatSessionState() + { + if (_nativeFatCanonicalGrid == null || string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + return; + + var cache = GetNativeFatSession(_nativeFatBoundIedKey); + if (_nativeFatCanonicalGrid.SelectedItem is Iec61850MonitorPoint point) + cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + cache.LastScrollIndex = Math.Max(0, _nativeFatCanonicalGrid.SelectedIndex); + } + + private void RestoreNativeFatSessionState(Iec61850MonitorDevice? device) + { + if (_nativeFatCanonicalGrid == null || device == null || string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + return; + + if (!_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache)) + return; + + Iec61850MonitorPoint? target = null; + if (!string.IsNullOrWhiteSpace(cache.ActiveRowKey)) + { + target = device.Points.FirstOrDefault(point => + string.Equals( + NativeFatCanonicalEvidenceOverlay.BuildRowKey(point), + cache.ActiveRowKey, + StringComparison.OrdinalIgnoreCase)); + } + + if (target == null && cache.LastScrollIndex >= 0 && cache.LastScrollIndex < device.Points.Count) + target = device.Points[cache.LastScrollIndex]; + + if (target == null) + return; + + _nativeFatCanonicalGrid.SelectedItem = target; + _nativeFatCanonicalGrid.ScrollIntoView(target); + } + + private NativeFatIedSessionCacheState GetNativeFatSession(string iedKey) + { + if (!_nativeFatSessionByIed.TryGetValue(iedKey, out var cache)) + { + cache = new NativeFatIedSessionCacheState(); + _nativeFatSessionByIed[iedKey] = cache; + } + return cache; + } + + private string ReadNativeFatEvidence(Iec61850MonitorPoint point, NativeFatEvidenceField field) + { + if (string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) || + !_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache)) + { + return string.Empty; + } + + return NativeFatCanonicalEvidenceOverlay.Read(cache, point, field); + } + + private void NativeFatCanonicalGrid_CellEditEnding(object? sender, DataGridCellEditEndingEventArgs e) + { + if (e.EditAction != DataGridEditAction.Commit || + e.Row.Item is not Iec61850MonitorPoint point || + e.Column is not NativeFatEvidenceColumn evidenceColumn || + e.EditingElement is not TextBox editor || + string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + { + return; + } + + var cache = GetNativeFatSession(_nativeFatBoundIedKey); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, evidenceColumn.Field, editor.Text); + cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + } + + private sealed class NativeFatEvidenceColumn : DataGridColumn + { + private readonly MainWindow _owner; + + internal NativeFatEvidenceColumn( + MainWindow owner, + string header, + NativeFatEvidenceField field, + double width) + { + _owner = owner; + Header = header; + Field = field; + Width = new DataGridLength(width); + MinWidth = 78; + } + + internal NativeFatEvidenceField Field { get; } + + protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem) + { + return new TextBlock + { + Text = dataItem is Iec61850MonitorPoint point + ? _owner.ReadNativeFatEvidence(point, Field) + : string.Empty, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + Padding = new Thickness(5, 0, 5, 0) + }; + } + + protected override FrameworkElement GenerateEditingElement(DataGridCell cell, object dataItem) + { + return new TextBox + { + Text = dataItem is Iec61850MonitorPoint point + ? _owner.ReadNativeFatEvidence(point, Field) + : string.Empty, + VerticalContentAlignment = VerticalAlignment.Center, + Padding = new Thickness(4, 1, 4, 1), + BorderThickness = new Thickness(1) + }; + } + } +} From 52e8fcea8e71d0cd424def87706778e31dfbd51f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 05:58:18 +0700 Subject: [PATCH 015/158] Complete P1A direct Engineering FAT surface --- MainWindow.ProductionFatTab.cs | 122 ++++++++++++--------------------- 1 file changed, 42 insertions(+), 80 deletions(-) diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index f09a3ae39..1a9d0288a 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -1,16 +1,14 @@ using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; -using System.Windows.Media; using System.Windows.Threading; -using ArIED61850Tester.Models; namespace ArIED61850Tester; /// -/// Engineering FAT pivot: the permanent XAML FAT tab hosts the proven production -/// IoListTestingWindow workspace rather than a second/manual FAT implementation. -/// The global Engineering IED Explorer and shared Command Dock remain authoritative. +/// Engineering FAT pivot. The normal FAT destination is a native view over the exact +/// Engineering live-row collection; the legacy IoListTestingWindow host remains available +/// only for explicit/manual compatibility workflows. /// public partial class MainWindow { @@ -36,9 +34,6 @@ private static void ProductionFatTab_MainWindowLoaded(object sender, RoutedEvent if (sender is not MainWindow window || window._productionFatTabInstalled) return; - // MainWindow.Loaded runs after XAML has materialized the canonical seventh tab but - // before the first normal render. Install the permanent FAT shell immediately so the - // operator never sees an empty/black seventh workspace while idle-dispatcher work waits. window.TryInstallProductionFatTabPivot(); } @@ -47,8 +42,6 @@ private void TryInstallProductionFatTabPivot() if (_productionFatTabInstalled || !IsLoaded) return; - // M7: MainWindow.xaml is the sole owner of the seventh destination. Wait only - // until the canonical XAML tab is present; there is no native FAT runtime to install. if (MainTabs.Items.Count <= NativeFatWorkspaceIndex || !ReferenceEquals(MainTabs.Items[NativeFatWorkspaceIndex], NativeFatTab)) { @@ -66,12 +59,11 @@ private void TryInstallProductionFatTabPivot() _productionFatTabInstalled = true; NativeFatTab.Content = BuildProductionFatPermanentHost(); - // Bootstrap remains navigation-gated. Queueing here is harmless because the - // Engineering bootstrap itself refuses to build FAT unless the FAT tab is active. - QueueProductionFatEngineeringBootstrap(); + // P1A: FAT installation is a view bind only. Do not queue the historical + // Engineering -> IoTest projection/bootstrap from normal FAT navigation. + SynchronizeProductionFatSelectedIed(); - // MainWindow.xaml owns both style and click routing for the seventh nav button. - NavNativeFatButton.ToolTip = "Production FAT workspace · automatic Value 1 / Value 2 evidence capture"; + NavNativeFatButton.ToolTip = "Factory Acceptance Test · canonical Engineering rows + sparse evidence"; PropertyChanged += ProductionFat_MainWindowPropertyChanged; MainTabs.SelectionChanged += ProductionFat_MainTabsSelectionChanged; @@ -90,67 +82,10 @@ private FrameworkElement BuildProductionFatPermanentHost( string? statusText = null, bool isBusy = false) { - var root = new Grid { Margin = new Thickness(0) }; - var card = new Border - { - MaxWidth = 520, - Padding = new Thickness(28, 24, 28, 22), - CornerRadius = new CornerRadius(18), - Background = TryFindResource("CardBackground") as Brush ?? Brushes.White, - BorderBrush = TryFindResource("CardBorder") as Brush ?? new SolidColorBrush(Color.FromRgb(220, 228, 239)), - BorderThickness = new Thickness(1), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - }; - var panel = new StackPanel - { - HorizontalAlignment = HorizontalAlignment.Stretch - }; - panel.Children.Add(new TextBlock - { - Text = "IEC 61850 FAT", - FontSize = 11, - FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("Accent") as Brush ?? Brushes.RoyalBlue, - HorizontalAlignment = HorizontalAlignment.Center - }); - panel.Children.Add(new TextBlock - { - Text = isBusy ? "Preparing production workspace" : "Production FAT workspace", - FontSize = 18, - FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("Ink") as Brush ?? Brushes.Black, - Margin = new Thickness(0, 5, 0, 0), - HorizontalAlignment = HorizontalAlignment.Center - }); - panel.Children.Add(new TextBlock - { - Text = string.IsNullOrWhiteSpace(statusText) - ? "Select an Engineering IED with static DataSet scope, then open FAT." - : statusText, - FontSize = 11.5, - Foreground = TryFindResource("Muted") as Brush ?? Brushes.DimGray, - TextAlignment = TextAlignment.Center, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 8, 0, 0) - }); - - if (isBusy) - { - panel.Children.Add(new ProgressBar - { - Height = 4, - IsIndeterminate = true, - BorderThickness = new Thickness(0), - Margin = new Thickness(0, 18, 0, 0), - Foreground = TryFindResource("Accent") as Brush ?? Brushes.RoyalBlue, - Background = new SolidColorBrush(Color.FromRgb(220, 231, 248)) - }); - } - - card.Child = panel; - root.Children.Add(card); - return root; + var effectiveStatus = isBusy && !string.IsNullOrWhiteSpace(statusText) + ? $"{statusText}" + : statusText; + return BuildNativeFatCanonicalWorkspace(effectiveStatus); } internal void ShowProductionFatBootstrapState(string message, bool isBusy) @@ -158,7 +93,16 @@ internal void ShowProductionFatBootstrapState(string message, bool isBusy) if (!ProductionFatTabReady || _productionFatWindow is { IsLoaded: true }) return; + // Legacy bootstrap diagnostics must not replace the canonical native FAT grid. + // Surface the message in the header while keeping Engineering rows visible. + if (_nativeFatStatusText != null) + { + _nativeFatStatusText.Text = message; + return; + } + NativeFatTab.Content = BuildProductionFatPermanentHost(message, isBusy); + SynchronizeProductionFatSelectedIed(); } private void ProductionFat_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) @@ -171,9 +115,11 @@ private void ProductionFat_MainTabsSelectionChanged(object sender, SelectionChan { SynchronizeProductionFatSelectedIed(); _productionFatWindow?.NotifyEmbeddedHostActivated(); + _nativeFatCanonicalGrid?.Focus(); } else { + SaveNativeFatSessionState(); _productionFatWindow?.Storage?.ScheduleSave(); } } @@ -185,7 +131,15 @@ private void ProductionFat_MainWindowPropertyChanged(object? sender, System.Comp } private void SynchronizeProductionFatSelectedIed() - => _productionFatWindow?.SelectEngineeringDeviceForEmbeddedFat(SelectedDevice); + { + if (_productionFatWindow is { IsLoaded: true }) + { + _productionFatWindow.SelectEngineeringDeviceForEmbeddedFat(SelectedDevice); + return; + } + + BindNativeFatCanonicalRows(); + } internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkElement surface) { @@ -194,6 +148,7 @@ internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkE if (!ProductionFatTabReady) return false; + SaveNativeFatSessionState(); _productionFatWindow = window; _productionFatSurface = surface; surface.DataContext = window; @@ -205,13 +160,11 @@ internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkE window.Closed += ProductionFatWindow_Closed; SynchronizeProductionFatSelectedIed(); - // Passive mount: prewarming must never navigate, hide/show, activate, or steal - // focus from the operator's current Engineering destination. window.RegisterEmbeddedHostCloseCleanup(); QueueNativeFatNavigationGeometry(); if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex) - SetStatus($"FAT ready in Engineering tab · {window.Project.Ieds.Count} IED · production auto-capture workflow."); + SetStatus($"FAT compatibility workspace · {window.Project.Ieds.Count} IED."); return true; } @@ -224,6 +177,7 @@ internal void UnmountProductionFatWorkspace(IoListTestingWindow window) _productionFatWindow = null; _productionFatSurface = null; NativeFatTab.Content = BuildProductionFatPermanentHost(); + SynchronizeProductionFatSelectedIed(); } private void ProductionFatWindow_Closed(object? sender, EventArgs e) @@ -241,5 +195,13 @@ private void ProductionFat_MainWindowClosed(object? sender, EventArgs e) _productionFatInstallRetry = null; _productionFatWindow = null; _productionFatSurface = null; + if (_nativeFatCanonicalGrid != null) + _nativeFatCanonicalGrid.CellEditEnding -= NativeFatCanonicalGrid_CellEditEnding; + _nativeFatCanonicalGrid = null; + _nativeFatIedText = null; + _nativeFatRowCountText = null; + _nativeFatStatusText = null; + _nativeFatSessionByIed.Clear(); + _nativeFatBoundIedKey = null; } } From 0258c09d0d512055f59e50e4b4c9cb2d38ba1616 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 05:58:29 +0700 Subject: [PATCH 016/158] Test P1B sparse evidence on canonical Engineering row keys --- .../NativeFatCanonicalEvidenceOverlayTests.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs diff --git a/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs b/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs new file mode 100644 index 000000000..fb226c4dd --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs @@ -0,0 +1,63 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatCanonicalEvidenceOverlayTests +{ + [Fact] + public void BuildRowKey_ReusesEngineeringPointKeyInsteadOfDisplayName() + { + var first = Point("dev-1", "Trip", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); + var second = Point("dev-1", "Trip", "AA1E1F06R4LD0/GGIO1.Ind2.stVal"); + + Assert.Equal(first.PointKey, NativeFatCanonicalEvidenceOverlay.BuildRowKey(first)); + Assert.Equal(second.PointKey, NativeFatCanonicalEvidenceOverlay.BuildRowKey(second)); + Assert.NotEqual( + NativeFatCanonicalEvidenceOverlay.BuildRowKey(first), + NativeFatCanonicalEvidenceOverlay.BuildRowKey(second)); + } + + [Fact] + public void ReadUntouchedEvidence_DoesNotAllocateShadowRow() + { + var cache = new NativeFatIedSessionCacheState(); + var point = Point("dev-1", "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + + Assert.Equal(string.Empty, + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Empty(cache.EvidenceByRow); + } + + [Fact] + public void WriteEvidence_UsesOneSparseSlotAndClearingLastValueRemovesIt() + { + var cache = new NativeFatIedSessionCacheState(); + var point = Point("dev-1", "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "OPEN"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, "CLOSE"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "PASS"); + + Assert.Single(cache.EvidenceByRow); + Assert.Equal("OPEN", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("CLOSE", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result)); + + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, ""); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, ""); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, ""); + + Assert.Empty(cache.EvidenceByRow); + } + + private static Iec61850MonitorPoint Point(string deviceId, string signalName, string reference) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN" + }; +} From 7a3323b8b583734c39bc381a69081ce96523379d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 05:58:45 +0700 Subject: [PATCH 017/158] Lock P1A direct rows and P1B overlay boundaries --- ...uctionFatP1CanonicalGridRegressionTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs diff --git a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs new file mode 100644 index 000000000..fd8602c83 --- /dev/null +++ b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs @@ -0,0 +1,58 @@ +namespace ARSAS.Tests; + +public sealed class ProductionFatP1CanonicalGridRegressionTests +{ + [Fact] + public void P1A_NormalFatEntryBindsExactEngineeringPointCollection() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var tabSource = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + Assert.Contains("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", gridSource, StringComparison.Ordinal); + Assert.Contains("BuildNativeFatCanonicalWorkspace", tabSource, StringComparison.Ordinal); + Assert.Contains("BindNativeFatCanonicalRows();", tabSource, StringComparison.Ordinal); + + Assert.DoesNotContain("IoFatEngineeringWorkspaceProjectionService", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("IoTestPointPlan", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("QueueProductionFatEngineeringBootstrap();", tabSource, StringComparison.Ordinal); + Assert.DoesNotContain("OpenDescribedSourcesAsync", tabSource, StringComparison.Ordinal); + } + + [Fact] + public void P1B_EvidenceColumnsRemainSparseOverlayNotRowWrappers() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var overlaySource = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); + + Assert.Contains("NativeFatEvidenceField.Value1", gridSource, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2", gridSource, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Result", gridSource, StringComparison.Ordinal); + Assert.Contains("NativeFatIedSessionCacheState", gridSource, StringComparison.Ordinal); + Assert.Contains("point.PointKey", overlaySource, StringComparison.Ordinal); + Assert.Contains("cache.EvidenceByRow.Remove(key)", overlaySource, StringComparison.Ordinal); + + Assert.DoesNotContain("ObservableCollection", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("new Iec61850MonitorPoint", gridSource, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From 1da860a5091f7c2d6c3878a22305f9612bf270b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:01:18 +0700 Subject: [PATCH 018/158] Add sparse native FAT overlay state --- Models/NativeFatEvidenceOverlayState.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 Models/NativeFatEvidenceOverlayState.cs diff --git a/Models/NativeFatEvidenceOverlayState.cs b/Models/NativeFatEvidenceOverlayState.cs new file mode 100644 index 000000000..584baa0c9 --- /dev/null +++ b/Models/NativeFatEvidenceOverlayState.cs @@ -0,0 +1,23 @@ +namespace ArIED61850Tester.Models; + +/// +/// FAT-only evidence payload layered over a canonical Engineering row. +/// +public sealed class NativeFatEvidenceSlotState +{ + public string Value1 { get; set; } = string.Empty; + public string Value2 { get; set; } = string.Empty; + public string Result { get; set; } = string.Empty; +} + +/// +/// Per-IED UI/evidence state. EvidenceByRow is sparse and keyed by the canonical +/// Engineering point key; it is not a second signal/row collection. +/// +public sealed class NativeFatIedSessionCacheState +{ + public string? ActiveRowKey { get; set; } + public int LastScrollIndex { get; set; } + public Dictionary EvidenceByRow { get; } = + new(StringComparer.OrdinalIgnoreCase); +} From ea3117bcdf687e88e2a8a22cc0b7c3879263d44b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:07:09 +0700 Subject: [PATCH 019/158] Preserve M6 FAT host authority contract --- MainWindow.ProductionFatTab.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index 1a9d0288a..66275d9af 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -141,6 +141,8 @@ private void SynchronizeProductionFatSelectedIed() BindNativeFatCanonicalRows(); } + // Compatibility host contract: the global Engineering IED Explorer and shared Command Dock remain authoritative. + // Mounting the explicit/manual IoListTestingWindow center must never replace those workstation shell owners. internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkElement surface) { ArgumentNullException.ThrowIfNull(window); From 9ade43b2fbb9da0ecbd4a7b989f9e46adbcbcdbc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:07:21 +0700 Subject: [PATCH 020/158] Harden P1 canonical grid regression semantics --- .../ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs index fd8602c83..3d17a6527 100644 --- a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs @@ -13,7 +13,7 @@ public void P1A_NormalFatEntryBindsExactEngineeringPointCollection() Assert.Contains("BindNativeFatCanonicalRows();", tabSource, StringComparison.Ordinal); Assert.DoesNotContain("IoFatEngineeringWorkspaceProjectionService", gridSource, StringComparison.Ordinal); - Assert.DoesNotContain("IoTestPointPlan", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("new IoTestPointPlan", gridSource, StringComparison.Ordinal); Assert.DoesNotContain("QueueProductionFatEngineeringBootstrap();", tabSource, StringComparison.Ordinal); Assert.DoesNotContain("OpenDescribedSourcesAsync", tabSource, StringComparison.Ordinal); } From 76baae71de53f191ae63010a9357264e7c7e438b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:18:37 +0700 Subject: [PATCH 021/158] P1C align native FAT grid with Engineering visual authority --- MainWindow.NativeFatCanonicalGrid.cs | 84 ++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/MainWindow.NativeFatCanonicalGrid.cs b/MainWindow.NativeFatCanonicalGrid.cs index e5d3aa14e..21b8e5736 100644 --- a/MainWindow.NativeFatCanonicalGrid.cs +++ b/MainWindow.NativeFatCanonicalGrid.cs @@ -22,6 +22,7 @@ public partial class MainWindow /// P1A: FAT renders the exact Engineering live-row objects. There is no projection, /// SCL parse, IoTestPointPlan collection, or second acquisition owner in this surface. /// P1B adds only three sparse evidence columns keyed outside those canonical rows. + /// P1C reuses the Engineering grid visual authority and virtualization contract. /// private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = null) { @@ -81,34 +82,26 @@ private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = n header.Child = headerGrid; root.Children.Add(header); + var modernDataGridStyle = FindResource("ModernDataGrid") as Style + ?? throw new InvalidOperationException("ModernDataGrid visual authority was not found."); + _nativeFatCanonicalGrid = new DataGrid { + Style = modernDataGridStyle, + RowStyle = BuildEngineeringLiveRowStyle(), + CellStyle = BuildEngineeringLiveCellStyle(), AutoGenerateColumns = false, CanUserAddRows = false, CanUserDeleteRows = false, - CanUserReorderColumns = false, - CanUserResizeColumns = true, IsReadOnly = false, - HeadersVisibility = DataGridHeadersVisibility.Column, - GridLinesVisibility = DataGridGridLinesVisibility.Horizontal, - BorderThickness = new Thickness(1), - BorderBrush = new SolidColorBrush(Color.FromRgb(225, 231, 240)), - Background = Brushes.White, - RowBackground = Brushes.White, - SelectionMode = DataGridSelectionMode.Single, - SelectionUnit = DataGridSelectionUnit.FullRow, - RowHeaderWidth = 0, - FrozenColumnCount = 1, + FrozenColumnCount = 2, EnableRowVirtualization = true, - EnableColumnVirtualization = true, - HorizontalGridLinesBrush = new SolidColorBrush(Color.FromRgb(232, 237, 245)), - VerticalGridLinesBrush = Brushes.Transparent + EnableColumnVirtualization = true }; VirtualizingPanel.SetIsVirtualizing(_nativeFatCanonicalGrid, true); VirtualizingPanel.SetVirtualizationMode(_nativeFatCanonicalGrid, VirtualizationMode.Recycling); ScrollViewer.SetCanContentScroll(_nativeFatCanonicalGrid, true); ScrollViewer.SetHorizontalScrollBarVisibility(_nativeFatCanonicalGrid, ScrollBarVisibility.Auto); - ScrollViewer.SetVerticalScrollBarVisibility(_nativeFatCanonicalGrid, ScrollBarVisibility.Auto); _nativeFatCanonicalGrid.CellEditEnding += NativeFatCanonicalGrid_CellEditEnding; AddCanonicalTextColumn("Status", nameof(Iec61850MonitorPoint.Status), 90); @@ -116,9 +109,9 @@ private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = n AddCanonicalTextColumn("Address", nameof(Iec61850MonitorPoint.IecTelegram), 210); AddCanonicalTextColumn("Message", nameof(Iec61850MonitorPoint.SignalName), 180); AddCanonicalTextColumn("Data Reference", nameof(Iec61850MonitorPoint.IecReference), 290); - AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 92); - AddCanonicalTextColumn("Timestamp", nameof(Iec61850MonitorPoint.DeviceTimestamp), 152); - AddCanonicalTextColumn("Value", nameof(Iec61850MonitorPoint.DisplayValue), 90); + AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 105); + AddCanonicalTextColumn("Timestamp", nameof(Iec61850MonitorPoint.DeviceTimestamp), 155); + AddCanonicalTemplateColumn("Value", "ProcessValueBadgeTemplate", 125); _nativeFatCanonicalGrid.Columns.Add(new NativeFatEvidenceColumn(this, "Value 1", NativeFatEvidenceField.Value1, 104)); _nativeFatCanonicalGrid.Columns.Add(new NativeFatEvidenceColumn(this, "Value 2", NativeFatEvidenceField.Value2, 104)); _nativeFatCanonicalGrid.Columns.Add(new NativeFatEvidenceColumn(this, "Result", NativeFatEvidenceField.Result, 104)); @@ -128,6 +121,35 @@ private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = n return root; } + private Style BuildEngineeringLiveRowStyle() + { + var style = new Style(typeof(DataGridRow), FindResource(typeof(DataGridRow)) as Style); + var changed = new DataTrigger + { + Binding = new Binding(nameof(Iec61850MonitorPoint.IsRecentlyChanged)), + Value = true + }; + changed.Setters.Add(new Setter(Control.BackgroundProperty, new SolidColorBrush(Color.FromRgb(255, 240, 168)))); + changed.Setters.Add(new Setter(Control.BorderBrushProperty, new SolidColorBrush(Color.FromRgb(245, 158, 11)))); + changed.Setters.Add(new Setter(Control.BorderThicknessProperty, new Thickness(4, 1, 1, 1))); + style.Triggers.Add(changed); + return style; + } + + private Style BuildEngineeringLiveCellStyle() + { + var style = new Style(typeof(DataGridCell), FindResource(typeof(DataGridCell)) as Style); + var changed = new DataTrigger + { + Binding = new Binding(nameof(Iec61850MonitorPoint.IsRecentlyChanged)), + Value = true + }; + changed.Setters.Add(new Setter(Control.BackgroundProperty, new SolidColorBrush(Color.FromRgb(255, 245, 194)))); + changed.Setters.Add(new Setter(Control.ForegroundProperty, new SolidColorBrush(Color.FromRgb(92, 59, 0)))); + style.Triggers.Add(changed); + return style; + } + private void AddCanonicalTextColumn(string header, string path, double width) { if (_nativeFatCanonicalGrid == null) @@ -142,6 +164,22 @@ private void AddCanonicalTextColumn(string header, string path, double width) }); } + private void AddCanonicalTemplateColumn(string header, string templateKey, double width) + { + if (_nativeFatCanonicalGrid == null) + return; + + var template = FindResource(templateKey) as DataTemplate + ?? throw new InvalidOperationException($"Engineering cell template '{templateKey}' was not found."); + _nativeFatCanonicalGrid.Columns.Add(new DataGridTemplateColumn + { + Header = header, + CellTemplate = template, + Width = new DataGridLength(width), + IsReadOnly = true + }); + } + private void BindNativeFatCanonicalRows() { if (_nativeFatCanonicalGrid == null || _productionFatWindow is { IsLoaded: true }) @@ -273,8 +311,7 @@ protected override FrameworkElement GenerateElement(DataGridCell cell, object da ? _owner.ReadNativeFatEvidence(point, Field) : string.Empty, VerticalAlignment = VerticalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - Padding = new Thickness(5, 0, 5, 0) + TextTrimming = TextTrimming.CharacterEllipsis }; } @@ -286,8 +323,9 @@ protected override FrameworkElement GenerateEditingElement(DataGridCell cell, ob ? _owner.ReadNativeFatEvidence(point, Field) : string.Empty, VerticalContentAlignment = VerticalAlignment.Center, - Padding = new Thickness(4, 1, 4, 1), - BorderThickness = new Thickness(1) + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0) }; } } From 467c498c257e3a4d36fc687a162ea4f136cc2794 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:18:54 +0700 Subject: [PATCH 022/158] Add P1C shared Engineering grid visual regression coverage --- ...uctionFatP1CanonicalGridRegressionTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs index 3d17a6527..3ffc6992f 100644 --- a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs @@ -35,6 +35,33 @@ public void P1B_EvidenceColumnsRemainSparseOverlayNotRowWrappers() Assert.DoesNotContain("new Iec61850MonitorPoint", gridSource, StringComparison.Ordinal); } + [Fact] + public void P1C_NativeFatUsesEngineeringGridStyleTemplateAndVirtualizationContract() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var engineeringXaml = File.ReadAllText(FindRepoFile("MainWindow.xaml")); + var appXaml = File.ReadAllText(FindRepoFile("App.xaml")); + + Assert.Contains("x:Key=\"ModernDataGrid\"", appXaml, StringComparison.Ordinal); + Assert.Contains("", appXaml, StringComparison.Ordinal); + Assert.Contains("", appXaml, StringComparison.Ordinal); + + Assert.Contains("Style=\"{StaticResource ModernDataGrid}\" FrozenColumnCount=\"2\"", engineeringXaml, StringComparison.Ordinal); + Assert.Contains("CellTemplate=\"{StaticResource ProcessValueBadgeTemplate}\"", engineeringXaml, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.VirtualizationMode=\"Recycling\"", engineeringXaml, StringComparison.Ordinal); + + Assert.Contains("FindResource(\"ModernDataGrid\") as Style", gridSource, StringComparison.Ordinal); + Assert.Contains("AddCanonicalTemplateColumn(\"Value\", \"ProcessValueBadgeTemplate\", 125);", gridSource, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.SetVirtualizationMode(_nativeFatCanonicalGrid, VirtualizationMode.Recycling);", gridSource, StringComparison.Ordinal); + Assert.Contains("RowStyle = BuildEngineeringLiveRowStyle()", gridSource, StringComparison.Ordinal); + Assert.Contains("CellStyle = BuildEngineeringLiveCellStyle()", gridSource, StringComparison.Ordinal); + + // Engineering FAT must inherit the shared 32 px authority rather than the + // legacy IoList FAT local 40 px row family. + Assert.DoesNotContain("RowHeight = 40", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("MinHeight = 40", gridSource, StringComparison.Ordinal); + } + private static string FindRepoFile(string relativePath) => Path.Combine(FindRepoRoot(), relativePath); From 22afb4b4966dafd5c97a7acbed7d655eecf445f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:35:49 +0700 Subject: [PATCH 023/158] Add P1D native FAT arm session state --- Models/NativeFatEvidenceOverlayState.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Models/NativeFatEvidenceOverlayState.cs b/Models/NativeFatEvidenceOverlayState.cs index 584baa0c9..dd1f59067 100644 --- a/Models/NativeFatEvidenceOverlayState.cs +++ b/Models/NativeFatEvidenceOverlayState.cs @@ -18,6 +18,9 @@ public sealed class NativeFatIedSessionCacheState { public string? ActiveRowKey { get; set; } public int LastScrollIndex { get; set; } + public bool IsArmed { get; set; } + public DateTimeOffset? ArmedAt { get; set; } + public long LastArmElapsedMilliseconds { get; set; } public Dictionary EvidenceByRow { get; } = new(StringComparer.OrdinalIgnoreCase); } From 113eb4577b1c02a0f30429a0bb4e56ba95803fb7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:36:08 +0700 Subject: [PATCH 024/158] Add P1D ARM-only native FAT coordinator --- Services/IoTesting/NativeFatArmCoordinator.cs | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 Services/IoTesting/NativeFatArmCoordinator.cs diff --git a/Services/IoTesting/NativeFatArmCoordinator.cs b/Services/IoTesting/NativeFatArmCoordinator.cs new file mode 100644 index 000000000..3132e017a --- /dev/null +++ b/Services/IoTesting/NativeFatArmCoordinator.cs @@ -0,0 +1,256 @@ +using System.ComponentModel; +using System.Diagnostics; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatArmResult( + bool Succeeded, + bool AlreadyArmed, + int ArmedRows, + int SeededValue1Rows, + long ElapsedMilliseconds, + string Message); + +public sealed class NativeFatEvidenceChangedEventArgs : EventArgs +{ + public NativeFatEvidenceChangedEventArgs( + string deviceId, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + DeviceId = deviceId; + Point = point; + Field = field; + } + + public string DeviceId { get; } + public Iec61850MonitorPoint Point { get; } + public NativeFatEvidenceField Field { get; } +} + +/// +/// P1D evidence ARM coordinator for the native Engineering FAT surface. +/// It never connects, discovers, imports SCL, changes reporting, changes polling cadence, +/// or creates a second point collection. It only subscribes to the canonical Engineering +/// Iec61850MonitorPoint instances that are already live and records sparse evidence. +/// +public sealed class NativeFatArmCoordinator : IDisposable +{ + private readonly Dictionary _armedDevices = + new(StringComparer.OrdinalIgnoreCase); + + public event EventHandler? EvidenceChanged; + + public NativeFatArmResult Arm( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(cache); + + var stopwatch = Stopwatch.StartNew(); + + if (!device.IsConnected || !device.IsMonitoring) + { + stopwatch.Stop(); + return new NativeFatArmResult( + false, + false, + 0, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} must already be connected and monitoring in Engineering before FAT can be armed."); + } + + if (device.Points.Count == 0) + { + stopwatch.Stop(); + return new NativeFatArmResult( + false, + false, + 0, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} has no canonical Engineering live rows to arm."); + } + + if (_armedDevices.TryGetValue(device.DeviceId, out var existing)) + { + stopwatch.Stop(); + cache.IsArmed = true; + cache.ArmedAt ??= existing.ArmedAt; + cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + return new NativeFatArmResult( + true, + true, + existing.Subscriptions.Count, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} FAT is already armed on the shared Engineering acquisition stream."); + } + + var armed = new ArmedDevice(device.DeviceId, cache, DateTimeOffset.Now); + var seeded = 0; + + foreach (var point in device.Points) + { + PropertyChangedEventHandler handler = (_, args) => + { + if (args.PropertyName is nameof(Iec61850MonitorPoint.Value) or nameof(Iec61850MonitorPoint.DisplayValue)) + ObserveCanonicalValue(armed, point); + }; + + point.PropertyChanged += handler; + armed.Subscriptions.Add(new PointSubscription(point, handler)); + if (ObserveCanonicalValue(armed, point)) + seeded++; + } + + _armedDevices[device.DeviceId] = armed; + cache.IsArmed = true; + cache.ArmedAt = armed.ArmedAt; + stopwatch.Stop(); + cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + return new NativeFatArmResult( + true, + false, + armed.Subscriptions.Count, + seeded, + stopwatch.ElapsedMilliseconds, + $"{device.Name} FAT armed on {armed.Subscriptions.Count} canonical Engineering row(s) in {stopwatch.ElapsedMilliseconds} ms; acquisition was not restarted."); + } + + public bool IsArmed(string? deviceId) + => !string.IsNullOrWhiteSpace(deviceId) && _armedDevices.ContainsKey(deviceId); + + public void Dispose() + { + foreach (var armed in _armedDevices.Values) + { + foreach (var subscription in armed.Subscriptions) + subscription.Point.PropertyChanged -= subscription.Handler; + armed.Cache.IsArmed = false; + } + + _armedDevices.Clear(); + } + + private bool ObserveCanonicalValue(ArmedDevice armed, Iec61850MonitorPoint point) + { + var value = point.DisplayValue?.Trim() ?? string.Empty; + if (!IsEvidenceCandidate(point, value)) + return false; + + lock (armed.Gate) + { + if (!armed.Cache.EvidenceByRow.TryGetValue( + NativeFatCanonicalEvidenceOverlay.BuildRowKey(point), + out var slot)) + { + NativeFatCanonicalEvidenceOverlay.Write( + armed.Cache, + point, + NativeFatEvidenceField.Value1, + value); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); + return true; + } + + if (string.IsNullOrWhiteSpace(slot.Value1)) + { + NativeFatCanonicalEvidenceOverlay.Write( + armed.Cache, + point, + NativeFatEvidenceField.Value1, + value); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); + return true; + } + + if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value1, value) || + (!string.IsNullOrWhiteSpace(slot.Value2) && + Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value2, value))) + { + return false; + } + + if (string.IsNullOrWhiteSpace(slot.Value2)) + { + NativeFatCanonicalEvidenceOverlay.Write( + armed.Cache, + point, + NativeFatEvidenceField.Value2, + value); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value2); + return true; + } + + // Keep the current pair aligned to the latest meaningful transition without + // touching Result, which remains an operator/report assessment field. + NativeFatCanonicalEvidenceOverlay.Write( + armed.Cache, + point, + NativeFatEvidenceField.Value1, + slot.Value2); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); + + NativeFatCanonicalEvidenceOverlay.Write( + armed.Cache, + point, + NativeFatEvidenceField.Value2, + value); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value2); + return true; + } + } + + private static bool IsEvidenceCandidate(Iec61850MonitorPoint point, string value) + { + if (string.IsNullOrWhiteSpace(value) || value is "-" or "—") + return false; + + if (value.Equals("Pending", StringComparison.OrdinalIgnoreCase) || + value.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var quality = point.Quality ?? string.Empty; + return !quality.Contains("bad", StringComparison.OrdinalIgnoreCase) && + !quality.Contains("invalid", StringComparison.OrdinalIgnoreCase) && + !quality.Contains("questionable", StringComparison.OrdinalIgnoreCase); + } + + private void RaiseEvidenceChanged( + string deviceId, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + => EvidenceChanged?.Invoke( + this, + new NativeFatEvidenceChangedEventArgs(deviceId, point, field)); + + private sealed class ArmedDevice + { + public ArmedDevice( + string deviceId, + NativeFatIedSessionCacheState cache, + DateTimeOffset armedAt) + { + DeviceId = deviceId; + Cache = cache; + ArmedAt = armedAt; + } + + public string DeviceId { get; } + public NativeFatIedSessionCacheState Cache { get; } + public DateTimeOffset ArmedAt { get; } + public object Gate { get; } = new(); + public List Subscriptions { get; } = new(); + } + + private sealed record PointSubscription( + Iec61850MonitorPoint Point, + PropertyChangedEventHandler Handler); +} From 4dca6aaa3f8b73351fbd3e9559bd2204da95079d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:36:23 +0700 Subject: [PATCH 025/158] Make P1D sparse FAT evidence access thread-safe --- .../NativeFatCanonicalEvidenceOverlay.cs | 76 ++++++++++--------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs index c6b1ebd3f..42c458197 100644 --- a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs +++ b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs @@ -41,16 +41,19 @@ public static string Read( ArgumentNullException.ThrowIfNull(cache); ArgumentNullException.ThrowIfNull(point); - if (!cache.EvidenceByRow.TryGetValue(BuildRowKey(point), out var slot)) - return string.Empty; - - return field switch + lock (cache.EvidenceByRow) { - NativeFatEvidenceField.Value1 => slot.Value1, - NativeFatEvidenceField.Value2 => slot.Value2, - NativeFatEvidenceField.Result => slot.Result, - _ => string.Empty - }; + if (!cache.EvidenceByRow.TryGetValue(BuildRowKey(point), out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => slot.Value1, + NativeFatEvidenceField.Value2 => slot.Value2, + NativeFatEvidenceField.Result => slot.Result, + _ => string.Empty + }; + } } public static void Write( @@ -65,36 +68,39 @@ public static void Write( var key = BuildRowKey(point); var text = value ?? string.Empty; - if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + lock (cache.EvidenceByRow) { - // Reading/clearing an untouched cell must not allocate evidence. - if (string.IsNullOrWhiteSpace(text)) - return; + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + { + // Reading/clearing an untouched cell must not allocate evidence. + if (string.IsNullOrWhiteSpace(text)) + return; - slot = new NativeFatEvidenceSlotState(); - cache.EvidenceByRow[key] = slot; - } + slot = new NativeFatEvidenceSlotState(); + cache.EvidenceByRow[key] = slot; + } - switch (field) - { - case NativeFatEvidenceField.Value1: - slot.Value1 = text; - break; - case NativeFatEvidenceField.Value2: - slot.Value2 = text; - break; - case NativeFatEvidenceField.Result: - slot.Result = text; - break; - } + switch (field) + { + case NativeFatEvidenceField.Value1: + slot.Value1 = text; + break; + case NativeFatEvidenceField.Value2: + slot.Value2 = text; + break; + case NativeFatEvidenceField.Result: + slot.Result = text; + break; + } - // Keep the overlay genuinely sparse. Clearing the last evidence value removes - // the entry rather than leaving a shadow row behind. - if (string.IsNullOrWhiteSpace(slot.Value1) && - string.IsNullOrWhiteSpace(slot.Value2) && - string.IsNullOrWhiteSpace(slot.Result)) - { - cache.EvidenceByRow.Remove(key); + // Keep the overlay genuinely sparse. Clearing the last evidence value removes + // the entry rather than leaving a shadow row behind. + if (string.IsNullOrWhiteSpace(slot.Value1) && + string.IsNullOrWhiteSpace(slot.Value2) && + string.IsNullOrWhiteSpace(slot.Result)) + { + cache.EvidenceByRow.Remove(key); + } } } } From eb73dbc0efcfefaae65a6afa4792f73ce6a47b32 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:37:08 +0700 Subject: [PATCH 026/158] Implement P1D ARM-only Engineering FAT start --- MainWindow.NativeFatCanonicalGrid.cs | 142 +++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/MainWindow.NativeFatCanonicalGrid.cs b/MainWindow.NativeFatCanonicalGrid.cs index 21b8e5736..55c2f7ac6 100644 --- a/MainWindow.NativeFatCanonicalGrid.cs +++ b/MainWindow.NativeFatCanonicalGrid.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Windows; using System.Windows.Controls; using System.Windows.Data; @@ -11,21 +12,31 @@ public partial class MainWindow { private readonly Dictionary _nativeFatSessionByIed = new(StringComparer.OrdinalIgnoreCase); + private readonly NativeFatArmCoordinator _nativeFatArmCoordinator = new(); private DataGrid? _nativeFatCanonicalGrid; private TextBlock? _nativeFatIedText; private TextBlock? _nativeFatRowCountText; private TextBlock? _nativeFatStatusText; + private Button? _nativeFatStartButton; private string? _nativeFatBoundIedKey; + private bool _nativeFatArmEventsHooked; /// /// P1A: FAT renders the exact Engineering live-row objects. There is no projection, /// SCL parse, IoTestPointPlan collection, or second acquisition owner in this surface. /// P1B adds only three sparse evidence columns keyed outside those canonical rows. /// P1C reuses the Engineering grid visual authority and virtualization contract. + /// P1D makes Start FAT an ARM-only operation over those already-live row objects. /// private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = null) { + if (!_nativeFatArmEventsHooked) + { + _nativeFatArmCoordinator.EvidenceChanged += NativeFatArmCoordinator_EvidenceChanged; + _nativeFatArmEventsHooked = true; + } + var root = new Grid { Margin = new Thickness(16) @@ -68,6 +79,12 @@ private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = n titlePanel.Children.Add(_nativeFatStatusText); headerGrid.Children.Add(titlePanel); + var actionPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(16, 0, 0, 0) + }; _nativeFatRowCountText = new TextBlock { Text = "0 rows", @@ -75,10 +92,24 @@ private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = n FontWeight = FontWeights.SemiBold, Foreground = TryFindResource("Accent") as Brush ?? Brushes.RoyalBlue, VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(16, 0, 0, 0) + Margin = new Thickness(0, 0, 12, 0) + }; + actionPanel.Children.Add(_nativeFatRowCountText); + + _nativeFatStartButton = new Button + { + Content = "Start FAT", + MinWidth = 92, + Padding = new Thickness(12, 6, 12, 6), + Style = TryFindResource("PrimaryButton") as Style, + IsEnabled = false, + ToolTip = "Arm FAT evidence on the already-running Engineering live stream." }; - Grid.SetColumn(_nativeFatRowCountText, 1); - headerGrid.Children.Add(_nativeFatRowCountText); + _nativeFatStartButton.Click += NativeFatStartButton_Click; + actionPanel.Children.Add(_nativeFatStartButton); + + Grid.SetColumn(actionPanel, 1); + headerGrid.Children.Add(actionPanel); header.Child = headerGrid; root.Children.Add(header); @@ -201,11 +232,96 @@ private void BindNativeFatCanonicalRows() ? "FAT · select an Engineering IED" : $"FAT · {device.Name} · {device.IpAddress}:{device.Port}"; _nativeFatRowCountText!.Text = device == null ? "0 rows" : $"{device.Points.Count} rows"; - _nativeFatStatusText!.Text = device == null - ? "Select an Engineering IED with canonical live rows." - : "Canonical Engineering live rows · no reconnect · sparse Value 1 / Value 2 / Result overlay"; RestoreNativeFatSessionState(device); + UpdateNativeFatArmUi(device); + } + + private void UpdateNativeFatArmUi(Iec61850MonitorDevice? device, string? overrideStatus = null) + { + if (_nativeFatStatusText == null || _nativeFatStartButton == null) + return; + + if (device == null) + { + _nativeFatStartButton.Content = "Start FAT"; + _nativeFatStartButton.IsEnabled = false; + _nativeFatStatusText.Text = overrideStatus ?? "Select an Engineering IED with canonical live rows."; + return; + } + + var cache = GetNativeFatSession(device.DeviceId); + var armed = cache.IsArmed || _nativeFatArmCoordinator.IsArmed(device.DeviceId); + _nativeFatStartButton.Content = armed ? "FAT Armed" : "Start FAT"; + _nativeFatStartButton.IsEnabled = !armed && device.IsConnected && device.IsMonitoring && device.Points.Count > 0; + _nativeFatStartButton.ToolTip = armed + ? "FAT evidence is armed on the existing Engineering acquisition stream." + : device.IsConnected && device.IsMonitoring + ? "Arm FAT evidence only. No reconnect, SCL import, discovery, report restart, or polling change." + : "Start Engineering monitoring first; FAT will reuse that live acquisition."; + + _nativeFatStatusText.Text = overrideStatus ?? (armed + ? $"FAT armed · shared Engineering acquisition untouched · {device.Points.Count} canonical row(s)" + : "Canonical Engineering live rows · Start FAT only arms evidence; acquisition remains untouched"); + } + + private void NativeFatStartButton_Click(object sender, RoutedEventArgs e) + { + var stopwatch = Stopwatch.StartNew(); + var device = SelectedDevice; + if (device == null) + { + UpdateNativeFatArmUi(null, "Select an Engineering IED before starting FAT."); + return; + } + + var cache = GetNativeFatSession(device.DeviceId); + var result = _nativeFatArmCoordinator.Arm(device, cache); + stopwatch.Stop(); + cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + var status = result.Succeeded + ? result.AlreadyArmed + ? result.Message + : $"{device.Name} FAT armed in {stopwatch.ElapsedMilliseconds} ms · {result.ArmedRows} canonical row(s) · {result.SeededValue1Rows} Value 1 seeded · no acquisition restart" + : result.Message; + + UpdateNativeFatArmUi(device, status); + SetStatus(result.Succeeded + ? $"FAT · {device.Name} armed on shared Engineering live data in {stopwatch.ElapsedMilliseconds} ms" + : $"FAT · {result.Message}"); + + Trace.WriteLine( + $"[FAT P1D] ARM completed in {stopwatch.ElapsedMilliseconds} ms; " + + $"ied={device.Name}; deviceId={device.DeviceId}; rows={device.Points.Count}; " + + $"seededV1={result.SeededValue1Rows}; alreadyArmed={result.AlreadyArmed}; succeeded={result.Succeeded}; " + + "networkPrepare=false; reconnect=false; sclImport=false; discovery=false; reportRestart=false; pollingChange=false."); + } + + private void NativeFatArmCoordinator_EvidenceChanged(object? sender, NativeFatEvidenceChangedEventArgs e) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(() => NativeFatArmCoordinator_EvidenceChanged(sender, e)); + return; + } + + if (!string.Equals(_nativeFatBoundIedKey, e.DeviceId, StringComparison.OrdinalIgnoreCase)) + return; + + RefreshNativeFatEvidenceCells(e.Point); + } + + private void RefreshNativeFatEvidenceCells(Iec61850MonitorPoint point) + { + if (_nativeFatCanonicalGrid == null) + return; + + foreach (var column in _nativeFatCanonicalGrid.Columns.OfType()) + { + if (column.GetCellContent(point) is TextBlock textBlock) + textBlock.Text = ReadNativeFatEvidence(point, column.Field); + } } private void SaveNativeFatSessionState() @@ -284,6 +400,20 @@ e.EditingElement is not TextBox editor || cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); } + private void DisposeNativeFatArmCoordinator() + { + if (_nativeFatArmEventsHooked) + { + _nativeFatArmCoordinator.EvidenceChanged -= NativeFatArmCoordinator_EvidenceChanged; + _nativeFatArmEventsHooked = false; + } + + _nativeFatArmCoordinator.Dispose(); + if (_nativeFatStartButton != null) + _nativeFatStartButton.Click -= NativeFatStartButton_Click; + _nativeFatStartButton = null; + } + private sealed class NativeFatEvidenceColumn : DataGridColumn { private readonly MainWindow _owner; From 13f9230d69fec3598a94697936639736cd75e596 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:37:32 +0700 Subject: [PATCH 027/158] Dispose P1D native FAT arm subscriptions on shutdown --- MainWindow.ProductionFatTab.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index 66275d9af..21bd6fb1b 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -199,6 +199,7 @@ private void ProductionFat_MainWindowClosed(object? sender, EventArgs e) _productionFatSurface = null; if (_nativeFatCanonicalGrid != null) _nativeFatCanonicalGrid.CellEditEnding -= NativeFatCanonicalGrid_CellEditEnding; + DisposeNativeFatArmCoordinator(); _nativeFatCanonicalGrid = null; _nativeFatIedText = null; _nativeFatRowCountText = null; From d90e426b28ce94adf1f9a7689b89acdf0854a9e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:37:54 +0700 Subject: [PATCH 028/158] Add P1D ARM-only FAT regression coverage --- .../NativeFatP1DArmCoordinatorTests.cs | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs diff --git a/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs b/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs new file mode 100644 index 000000000..7d4d98456 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs @@ -0,0 +1,160 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP1DArmCoordinatorTests +{ + [Fact] + public void Arm_RequiresAlreadyRunningEngineeringMonitoring() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device(isConnected: true, isMonitoring: false); + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + + Assert.False(result.Succeeded); + Assert.False(cache.IsArmed); + Assert.Empty(cache.EvidenceByRow); + } + + [Fact] + public void Arm_SeedsValue1AndCapturesValue2WithoutReplacingCanonicalRows() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device(isConnected: true, isMonitoring: true); + var point = Point(device.DeviceId, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal", "Open [01]"); + device.Points.Add(point); + var canonicalReference = device.Points[0]; + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + + Assert.True(result.Succeeded); + Assert.True(cache.IsArmed); + Assert.Equal(1, result.ArmedRows); + Assert.Equal(1, result.SeededValue1Rows); + Assert.Single(device.Points); + Assert.Same(canonicalReference, device.Points[0]); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); + + point.Value = "Closed [10]"; + + Assert.Single(device.Points); + Assert.Same(canonicalReference, device.Points[0]); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result)); + } + + [Fact] + public void Arm_LatestMeaningfulTransitionRollsValuePairAndLeavesResultOperatorOwned() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device(isConnected: true, isMonitoring: true); + var point = Point(device.DeviceId, "Status", "AA1E1F06R4LD0/GGIO1.Ind1.stVal", "False"); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "REVIEW"); + + Assert.True(coordinator.Arm(device, cache).Succeeded); + point.Value = "True"; + point.Value = "False"; + + Assert.Equal("True", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("False", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal("REVIEW", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result)); + } + + [Fact] + public void NativeStartHandler_IsSynchronousArmOnlyAndCannotEnterLegacyPreparation() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var method = ExtractMethod(source, "private void NativeFatStartButton_Click(object sender, RoutedEventArgs e)"); + + Assert.Contains("Stopwatch.StartNew()", method, StringComparison.Ordinal); + Assert.Contains("_nativeFatArmCoordinator.Arm(device, cache)", method, StringComparison.Ordinal); + Assert.Contains("networkPrepare=false", method, StringComparison.Ordinal); + Assert.Contains("reconnect=false", method, StringComparison.Ordinal); + Assert.Contains("sclImport=false", method, StringComparison.Ordinal); + Assert.Contains("discovery=false", method, StringComparison.Ordinal); + Assert.Contains("reportRestart=false", method, StringComparison.Ordinal); + Assert.Contains("pollingChange=false", method, StringComparison.Ordinal); + + Assert.DoesNotContain("await ", method, StringComparison.Ordinal); + Assert.DoesNotContain("PrepareIoTestIedForFatAsync", method, StringComparison.Ordinal); + Assert.DoesNotContain("OpenDescribedSourcesAsync", method, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", method, StringComparison.Ordinal); + Assert.DoesNotContain("StartMonitoringAsync", method, StringComparison.Ordinal); + Assert.DoesNotContain("IoFatEngineeringWorkspaceProjectionService", method, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(bool isConnected, bool isMonitoring) + => new() + { + DeviceId = "dev-aa1e1f06r4", + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = isConnected, + IsMonitoring = isMonitoring + }; + + private static Iec61850MonitorPoint Point( + string deviceId, + string signalName, + string reference, + string value) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Status = "Live", + SourceMode = "Static DataSet reporting", + Value = value + }; + + private static string ExtractMethod(string source, string signature) + { + var start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"Could not find method '{signature}'."); + var openBrace = source.IndexOf('{', start); + Assert.True(openBrace >= 0, $"Could not find opening brace for '{signature}'."); + + var depth = 0; + for (var index = openBrace; index < source.Length; index++) + { + if (source[index] == '{') depth++; + else if (source[index] == '}' && --depth == 0) return source[start..(index + 1)]; + } + + throw new InvalidDataException($"Method '{signature}' has no balanced closing brace."); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From ab453526a5ca44f01e5725a22a8336ed817ac22e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 06:38:19 +0700 Subject: [PATCH 029/158] Fix P1D rolling evidence transition semantics --- Services/IoTesting/NativeFatArmCoordinator.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Services/IoTesting/NativeFatArmCoordinator.cs b/Services/IoTesting/NativeFatArmCoordinator.cs index 3132e017a..3da1a59b6 100644 --- a/Services/IoTesting/NativeFatArmCoordinator.cs +++ b/Services/IoTesting/NativeFatArmCoordinator.cs @@ -169,15 +169,11 @@ private bool ObserveCanonicalValue(ArmedDevice armed, Iec61850MonitorPoint point return true; } - if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value1, value) || - (!string.IsNullOrWhiteSpace(slot.Value2) && - Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value2, value))) - { - return false; - } - if (string.IsNullOrWhiteSpace(slot.Value2)) { + if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value1, value)) + return false; + NativeFatCanonicalEvidenceOverlay.Write( armed.Cache, point, @@ -187,6 +183,11 @@ private bool ObserveCanonicalValue(ArmedDevice armed, Iec61850MonitorPoint point return true; } + // Once a pair exists, only the newest Value 2 is the duplicate guard. A return + // to the prior Value 1 is itself a real transition and must advance the pair. + if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value2, value)) + return false; + // Keep the current pair aligned to the latest meaningful transition without // touching Result, which remains an operator/report assessment field. NativeFatCanonicalEvidenceOverlay.Write( From 2b88ce68570052fef1308a4ea4223c6133c6819e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:03:09 +0700 Subject: [PATCH 030/158] Add P2 native FAT evidence hydration state --- Models/NativeFatEvidenceOverlayState.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Models/NativeFatEvidenceOverlayState.cs b/Models/NativeFatEvidenceOverlayState.cs index dd1f59067..d01a9e78b 100644 --- a/Models/NativeFatEvidenceOverlayState.cs +++ b/Models/NativeFatEvidenceOverlayState.cs @@ -10,6 +10,14 @@ public sealed class NativeFatEvidenceSlotState public string Result { get; set; } = string.Empty; } +public enum NativeFatEvidenceHydrationState +{ + NotStarted, + Hydrating, + Resolved, + Failed +} + /// /// Per-IED UI/evidence state. EvidenceByRow is sparse and keyed by the canonical /// Engineering point key; it is not a second signal/row collection. @@ -21,6 +29,18 @@ public sealed class NativeFatIedSessionCacheState public bool IsArmed { get; set; } public DateTimeOffset? ArmedAt { get; set; } public long LastArmElapsedMilliseconds { get; set; } + + // P2 evidence hydration is deliberately independent from canonical row binding. + // Engineering rows render immediately; only the three sparse evidence columns wait. + public NativeFatEvidenceHydrationState EvidenceHydrationState { get; set; } = + NativeFatEvidenceHydrationState.NotStarted; + public long EvidenceHydrationGeneration { get; set; } + public DateTimeOffset? EvidenceHydratedAt { get; set; } + public string EvidenceHydrationError { get; set; } = string.Empty; + public long LastHydrationElapsedMilliseconds { get; set; } + public bool IsEvidenceHydrating => + EvidenceHydrationState == NativeFatEvidenceHydrationState.Hydrating; + public Dictionary EvidenceByRow { get; } = new(StringComparer.OrdinalIgnoreCase); } From 926d7a84a61f14125d65bf5663aee03a9f08c52e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:03:25 +0700 Subject: [PATCH 031/158] Add P2 snapshot and merge helpers for sparse evidence --- .../NativeFatCanonicalEvidenceOverlay.cs | 99 +++++++++++++++++-- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs index 42c458197..d52386b84 100644 --- a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs +++ b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs @@ -93,14 +93,101 @@ public static void Write( break; } - // Keep the overlay genuinely sparse. Clearing the last evidence value removes - // the entry rather than leaving a shadow row behind. - if (string.IsNullOrWhiteSpace(slot.Value1) && - string.IsNullOrWhiteSpace(slot.Value2) && - string.IsNullOrWhiteSpace(slot.Result)) + RemoveIfEmpty(cache, key, slot); + } + } + + /// + /// P2 merge rule: persisted evidence may fill missing cells but may never overwrite + /// evidence captured after hydration started. This lets Start FAT remain usable while + /// disk hydration is still completing. + /// + public static int MergeMissing( + NativeFatIedSessionCacheState cache, + IReadOnlyDictionary hydratedEvidence) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(hydratedEvidence); + + var mergedRows = 0; + lock (cache.EvidenceByRow) + { + foreach (var pair in hydratedEvidence) { - cache.EvidenceByRow.Remove(key); + var incoming = pair.Value; + if (incoming == null || + (string.IsNullOrWhiteSpace(incoming.Value1) && + string.IsNullOrWhiteSpace(incoming.Value2) && + string.IsNullOrWhiteSpace(incoming.Result))) + { + continue; + } + + if (!cache.EvidenceByRow.TryGetValue(pair.Key, out var current)) + { + cache.EvidenceByRow[pair.Key] = Clone(incoming); + mergedRows++; + continue; + } + + var changed = false; + if (string.IsNullOrWhiteSpace(current.Value1) && !string.IsNullOrWhiteSpace(incoming.Value1)) + { + current.Value1 = incoming.Value1; + changed = true; + } + if (string.IsNullOrWhiteSpace(current.Value2) && !string.IsNullOrWhiteSpace(incoming.Value2)) + { + current.Value2 = incoming.Value2; + changed = true; + } + if (string.IsNullOrWhiteSpace(current.Result) && !string.IsNullOrWhiteSpace(incoming.Result)) + { + current.Result = incoming.Result; + changed = true; + } + + if (changed) + mergedRows++; } } + + return mergedRows; + } + + public static IReadOnlyDictionary Snapshot( + NativeFatIedSessionCacheState cache) + { + ArgumentNullException.ThrowIfNull(cache); + lock (cache.EvidenceByRow) + { + return cache.EvidenceByRow.ToDictionary( + pair => pair.Key, + pair => Clone(pair.Value), + StringComparer.OrdinalIgnoreCase); + } + } + + private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) + => new() + { + Value1 = source.Value1, + Value2 = source.Value2, + Result = source.Result + }; + + private static void RemoveIfEmpty( + NativeFatIedSessionCacheState cache, + string key, + NativeFatEvidenceSlotState slot) + { + // Keep the overlay genuinely sparse. Clearing the last evidence value removes + // the entry rather than leaving a shadow row behind. + if (string.IsNullOrWhiteSpace(slot.Value1) && + string.IsNullOrWhiteSpace(slot.Value2) && + string.IsNullOrWhiteSpace(slot.Result)) + { + cache.EvidenceByRow.Remove(key); + } } } From 003b4356cfa5b7314404625e348eb395d2257f81 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:03:54 +0700 Subject: [PATCH 032/158] Add P2 async native FAT evidence hydration --- .../NativeFatEvidenceHydrationService.cs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 Services/IoTesting/NativeFatEvidenceHydrationService.cs diff --git a/Services/IoTesting/NativeFatEvidenceHydrationService.cs b/Services/IoTesting/NativeFatEvidenceHydrationService.cs new file mode 100644 index 000000000..e2685f956 --- /dev/null +++ b/Services/IoTesting/NativeFatEvidenceHydrationService.cs @@ -0,0 +1,269 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatEvidenceHydrationResult( + bool Succeeded, + bool SnapshotFound, + int LoadedRows, + int IgnoredRows, + long ElapsedMilliseconds, + string Message, + IReadOnlyDictionary EvidenceByRow); + +/// +/// P2 local evidence persistence/hydration for the native Engineering FAT surface. +/// This service owns only sparse Value 1 / Value 2 / Result data. It never touches +/// Engineering acquisition, SCL, reports, MMS sessions, polling cadence, or canonical rows. +/// +public sealed class NativeFatEvidenceHydrationService : IDisposable +{ + internal const string SnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-1.0"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + private readonly string _rootDirectory; + private readonly SemaphoreSlim _ioGate = new(1, 1); + private bool _disposed; + + public NativeFatEvidenceHydrationService(string? rootDirectory = null) + { + _rootDirectory = string.IsNullOrWhiteSpace(rootDirectory) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "Native FAT Evidence") + : Path.GetFullPath(rootDirectory); + } + + public async Task HydrateAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ThrowIfDisposed(); + + // Snapshot canonical identity synchronously on the caller/UI thread. Everything + // after this point is file IO / JSON work and does not enumerate the live collection. + var identity = CaptureIdentity(device); + var canonicalKeys = device.Points + .Select(NativeFatCanonicalEvidenceOverlay.BuildRowKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var stopwatch = Stopwatch.StartNew(); + var path = SnapshotPath(identity.DeviceId); + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (!File.Exists(path)) + { + stopwatch.Stop(); + return EmptyResult( + stopwatch.ElapsedMilliseconds, + $"No saved FAT evidence exists yet for {identity.DeviceName}."); + } + + var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); + var document = JsonSerializer.Deserialize(bytes, JsonOptions) + ?? throw new InvalidDataException("Native FAT evidence snapshot is invalid."); + + if (!string.Equals(document.Schema, SnapshotSchema, StringComparison.Ordinal)) + throw new InvalidDataException($"Unsupported native FAT evidence schema '{document.Schema}'."); + if (!string.Equals(document.DeviceId, identity.DeviceId, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Native FAT evidence belongs to a different Engineering device identity."); + + var loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ignored = 0; + foreach (var pair in document.EvidenceByRow ?? new Dictionary()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!canonicalKeys.Contains(pair.Key)) + { + ignored++; + continue; + } + + var source = pair.Value; + if (source == null || + (string.IsNullOrWhiteSpace(source.Value1) && + string.IsNullOrWhiteSpace(source.Value2) && + string.IsNullOrWhiteSpace(source.Result))) + { + continue; + } + + loaded[pair.Key] = Clone(source); + } + + stopwatch.Stop(); + return new NativeFatEvidenceHydrationResult( + true, + true, + loaded.Count, + ignored, + stopwatch.ElapsedMilliseconds, + $"Restored {loaded.Count} sparse evidence row(s) for {identity.DeviceName} in {stopwatch.ElapsedMilliseconds} ms.", + loaded); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + stopwatch.Stop(); + return new NativeFatEvidenceHydrationResult( + false, + File.Exists(path), + 0, + 0, + stopwatch.ElapsedMilliseconds, + ex.Message, + new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + finally + { + _ioGate.Release(); + } + } + + public async Task SaveAsync( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(cache); + ThrowIfDisposed(); + + // Capture device/row identity before yielding so background persistence never walks + // a WPF-bound ObservableCollection. Evidence itself is copied under its own lock. + var identity = CaptureIdentity(device); + var canonicalKeys = device.Points + .Select(NativeFatCanonicalEvidenceOverlay.BuildRowKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var evidence = NativeFatCanonicalEvidenceOverlay.Snapshot(cache) + .Where(pair => canonicalKeys.Contains(pair.Key)) + .ToDictionary( + pair => pair.Key, + pair => Clone(pair.Value), + StringComparer.OrdinalIgnoreCase); + var document = new NativeFatEvidenceDocument + { + Schema = SnapshotSchema, + SavedAtUtc = DateTimeOffset.UtcNow, + DeviceId = identity.DeviceId, + DeviceName = identity.DeviceName, + IpAddress = identity.IpAddress, + EvidenceByRow = evidence + }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions); + var path = SnapshotPath(identity.DeviceId); + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(_rootDirectory); + var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + await File.WriteAllBytesAsync(temporary, bytes, cancellationToken).ConfigureAwait(false); + File.Move(temporary, path, true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + finally + { + _ioGate.Release(); + } + } + + internal string SnapshotPath(string deviceId) + { + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(deviceId.Trim().ToLowerInvariant())); + var token = Convert.ToHexString(digest).ToLowerInvariant()[..24]; + return Path.Combine(_rootDirectory, $"{token}.native-fat-evidence.json"); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _ioGate.Dispose(); + } + + private static NativeFatDeviceIdentity CaptureIdentity(Iec61850MonitorDevice device) + => new( + device.DeviceId, + device.Name, + device.IpAddress); + + private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) + => new() + { + Value1 = source.Value1, + Value2 = source.Value2, + Result = source.Result + }; + + private static NativeFatEvidenceHydrationResult EmptyResult(long elapsedMilliseconds, string message) + => new( + true, + false, + 0, + 0, + elapsedMilliseconds, + message, + new Dictionary(StringComparer.OrdinalIgnoreCase)); + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed record NativeFatDeviceIdentity( + string DeviceId, + string DeviceName, + string IpAddress); + + private sealed class NativeFatEvidenceDocument + { + public string Schema { get; set; } = SnapshotSchema; + public DateTimeOffset SavedAtUtc { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string DeviceName { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public Dictionary EvidenceByRow { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } +} + +public static class NativeFatEvidenceLoadingPresentation +{ + /// + /// One shared UI clock advances this phase for every unresolved evidence cell. + /// No cell owns a timer or animation object. + /// + public static string RollingDots(int phase) + => Math.Abs(phase) % 3 switch + { + 0 => "·", + 1 => "··", + _ => "···" + }; +} From 99c226dc47b94cfb4e1965e8c011dde1da72d147 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:05:05 +0700 Subject: [PATCH 033/158] Implement P2 async evidence hydration and shared rolling clock --- MainWindow.NativeFatCanonicalGrid.cs | 271 ++++++++++++++++++++++++++- 1 file changed, 267 insertions(+), 4 deletions(-) diff --git a/MainWindow.NativeFatCanonicalGrid.cs b/MainWindow.NativeFatCanonicalGrid.cs index 55c2f7ac6..9117377e3 100644 --- a/MainWindow.NativeFatCanonicalGrid.cs +++ b/MainWindow.NativeFatCanonicalGrid.cs @@ -3,6 +3,7 @@ using System.Windows.Controls; using System.Windows.Data; using System.Windows.Media; +using System.Windows.Threading; using ArIED61850Tester.Models; using ArIED61850Tester.Services.IoTesting; @@ -13,6 +14,9 @@ public partial class MainWindow private readonly Dictionary _nativeFatSessionByIed = new(StringComparer.OrdinalIgnoreCase); private readonly NativeFatArmCoordinator _nativeFatArmCoordinator = new(); + private readonly NativeFatEvidenceHydrationService _nativeFatEvidenceHydrationService = new(); + private readonly Dictionary _nativeFatEvidencePersistCtsByIed = + new(StringComparer.OrdinalIgnoreCase); private DataGrid? _nativeFatCanonicalGrid; private TextBlock? _nativeFatIedText; @@ -21,6 +25,10 @@ public partial class MainWindow private Button? _nativeFatStartButton; private string? _nativeFatBoundIedKey; private bool _nativeFatArmEventsHooked; + private CancellationTokenSource? _nativeFatEvidenceHydrationCts; + private DispatcherTimer? _nativeFatEvidenceClock; + private int _nativeFatEvidenceClockPhase; + private long _nativeFatEvidenceHydrationGeneration; /// /// P1A: FAT renders the exact Engineering live-row objects. There is no projection, @@ -28,6 +36,7 @@ public partial class MainWindow /// P1B adds only three sparse evidence columns keyed outside those canonical rows. /// P1C reuses the Engineering grid visual authority and virtualization contract. /// P1D makes Start FAT an ARM-only operation over those already-live row objects. + /// P2 hydrates only sparse evidence asynchronously; canonical rows and live Value never wait. /// private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = null) { @@ -134,6 +143,7 @@ private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = n ScrollViewer.SetCanContentScroll(_nativeFatCanonicalGrid, true); ScrollViewer.SetHorizontalScrollBarVisibility(_nativeFatCanonicalGrid, ScrollBarVisibility.Auto); _nativeFatCanonicalGrid.CellEditEnding += NativeFatCanonicalGrid_CellEditEnding; + _nativeFatCanonicalGrid.BeginningEdit += NativeFatCanonicalGrid_BeginningEdit; AddCanonicalTextColumn("Status", nameof(Iec61850MonitorPoint.Status), 90); AddCanonicalTextColumn("Type", nameof(Iec61850MonitorPoint.IecDataType), 84); @@ -220,12 +230,13 @@ private void BindNativeFatCanonicalRows() _nativeFatCanonicalGrid.CommitEdit(DataGridEditingUnit.Cell, true); _nativeFatCanonicalGrid.CommitEdit(DataGridEditingUnit.Row, true); SaveNativeFatSessionState(); + CancelNativeFatEvidenceHydration(resetOldState: true); var device = SelectedDevice; _nativeFatBoundIedKey = device?.DeviceId; - // P1A invariant: this is the exact same collection used by Engineering. - // No Select/ToList/projection/wrapper is allowed here. + // P1A invariant: canonical rows and live values bind FIRST and synchronously. + // P2 evidence hydration starts only after this exact Engineering collection is visible. _nativeFatCanonicalGrid.ItemsSource = device?.Points; _nativeFatIedText!.Text = device == null @@ -235,6 +246,160 @@ private void BindNativeFatCanonicalRows() RestoreNativeFatSessionState(device); UpdateNativeFatArmUi(device); + BeginNativeFatEvidenceHydration(device); + } + + private void BeginNativeFatEvidenceHydration(Iec61850MonitorDevice? device) + { + if (device == null) + { + StopNativeFatEvidenceClock(); + return; + } + + var cache = GetNativeFatSession(device.DeviceId); + if (cache.EvidenceHydrationState == NativeFatEvidenceHydrationState.Resolved) + { + StopNativeFatEvidenceClock(); + RefreshAllVisibleNativeFatEvidenceCells(); + return; + } + + var generation = Interlocked.Increment(ref _nativeFatEvidenceHydrationGeneration); + var cts = new CancellationTokenSource(); + _nativeFatEvidenceHydrationCts = cts; + cache.EvidenceHydrationGeneration = generation; + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.Hydrating; + cache.EvidenceHydrationError = string.Empty; + cache.EvidenceHydratedAt = null; + + StartNativeFatEvidenceClock(); + RefreshAllVisibleNativeFatEvidenceCells(); + _ = HydrateNativeFatEvidenceAsync(device, cache, generation, cts.Token); + } + + private async Task HydrateNativeFatEvidenceAsync( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache, + long generation, + CancellationToken cancellationToken) + { + try + { + var result = await _nativeFatEvidenceHydrationService.HydrateAsync(device, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + if (generation != cache.EvidenceHydrationGeneration || + !string.Equals(_nativeFatBoundIedKey, device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + cache.LastHydrationElapsedMilliseconds = result.ElapsedMilliseconds; + if (result.Succeeded) + { + var merged = NativeFatCanonicalEvidenceOverlay.MergeMissing(cache, result.EvidenceByRow); + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.Resolved; + cache.EvidenceHydratedAt = DateTimeOffset.Now; + cache.EvidenceHydrationError = string.Empty; + + var armed = cache.IsArmed || _nativeFatArmCoordinator.IsArmed(device.DeviceId); + var evidenceStatus = result.SnapshotFound + ? $"evidence ready · {merged} persisted row(s) restored in {result.ElapsedMilliseconds} ms" + : $"evidence ready · no saved evidence · {result.ElapsedMilliseconds} ms"; + UpdateNativeFatArmUi( + device, + armed + ? $"FAT armed · shared Engineering acquisition untouched · {evidenceStatus}" + : $"Canonical Engineering live rows · {evidenceStatus}"); + } + else + { + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.Failed; + cache.EvidenceHydratedAt = DateTimeOffset.Now; + cache.EvidenceHydrationError = result.Message; + UpdateNativeFatArmUi( + device, + $"Canonical rows are live · saved FAT evidence could not be hydrated: {result.Message}"); + } + + Trace.WriteLine( + $"[FAT P2] evidence hydration completed in {result.ElapsedMilliseconds} ms; " + + $"ied={device.Name}; deviceId={device.DeviceId}; found={result.SnapshotFound}; " + + $"loaded={result.LoadedRows}; ignored={result.IgnoredRows}; succeeded={result.Succeeded}; " + + "canonicalRowsBlocked=false; networkCalls=0; rowRebuilds=0."); + } + catch (OperationCanceledException) + { + if (generation == cache.EvidenceHydrationGeneration && + cache.EvidenceHydrationState == NativeFatEvidenceHydrationState.Hydrating) + { + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.NotStarted; + } + } + finally + { + if (generation == cache.EvidenceHydrationGeneration && + string.Equals(_nativeFatBoundIedKey, device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + StopNativeFatEvidenceClock(); + RefreshAllVisibleNativeFatEvidenceCells(); + } + } + } + + private void StartNativeFatEvidenceClock() + { + _nativeFatEvidenceClock ??= CreateNativeFatEvidenceClock(); + if (!_nativeFatEvidenceClock.IsEnabled) + _nativeFatEvidenceClock.Start(); + } + + private DispatcherTimer CreateNativeFatEvidenceClock() + { + var timer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher) + { + Interval = TimeSpan.FromMilliseconds(320) + }; + timer.Tick += NativeFatEvidenceClock_Tick; + return timer; + } + + private void NativeFatEvidenceClock_Tick(object? sender, EventArgs e) + { + if (string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) || + !_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache) || + !cache.IsEvidenceHydrating) + { + StopNativeFatEvidenceClock(); + return; + } + + _nativeFatEvidenceClockPhase = (_nativeFatEvidenceClockPhase + 1) % 3; + RefreshAllVisibleNativeFatEvidenceCells(); + } + + private void StopNativeFatEvidenceClock() + { + _nativeFatEvidenceClock?.Stop(); + _nativeFatEvidenceClockPhase = 0; + } + + private void CancelNativeFatEvidenceHydration(bool resetOldState) + { + _nativeFatEvidenceHydrationCts?.Cancel(); + _nativeFatEvidenceHydrationCts?.Dispose(); + _nativeFatEvidenceHydrationCts = null; + + if (resetOldState && + !string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) && + _nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var oldCache) && + oldCache.EvidenceHydrationState == NativeFatEvidenceHydrationState.Hydrating) + { + oldCache.EvidenceHydrationState = NativeFatEvidenceHydrationState.NotStarted; + } + + StopNativeFatEvidenceClock(); } private void UpdateNativeFatArmUi(Iec61850MonitorDevice? device, string? overrideStatus = null) @@ -262,7 +427,9 @@ private void UpdateNativeFatArmUi(Iec61850MonitorDevice? device, string? overrid _nativeFatStatusText.Text = overrideStatus ?? (armed ? $"FAT armed · shared Engineering acquisition untouched · {device.Points.Count} canonical row(s)" - : "Canonical Engineering live rows · Start FAT only arms evidence; acquisition remains untouched"); + : cache.IsEvidenceHydrating + ? "Canonical Engineering rows are live · restoring only FAT evidence in background" + : "Canonical Engineering live rows · Start FAT only arms evidence; acquisition remains untouched"); } private void NativeFatStartButton_Click(object sender, RoutedEventArgs e) @@ -306,12 +473,61 @@ private void NativeFatArmCoordinator_EvidenceChanged(object? sender, NativeFatEv return; } + ScheduleNativeFatEvidencePersist(e.DeviceId); if (!string.Equals(_nativeFatBoundIedKey, e.DeviceId, StringComparison.OrdinalIgnoreCase)) return; RefreshNativeFatEvidenceCells(e.Point); } + private void ScheduleNativeFatEvidencePersist(string deviceId) + { + var device = Devices.FirstOrDefault(candidate => + candidate.DeviceId.Equals(deviceId, StringComparison.OrdinalIgnoreCase)); + if (device == null || !_nativeFatSessionByIed.TryGetValue(deviceId, out var cache)) + return; + + if (_nativeFatEvidencePersistCtsByIed.Remove(deviceId, out var previous)) + { + previous.Cancel(); + previous.Dispose(); + } + + var cts = new CancellationTokenSource(); + _nativeFatEvidencePersistCtsByIed[deviceId] = cts; + _ = PersistNativeFatEvidenceAfterDebounceAsync(device, cache, cts); + } + + private async Task PersistNativeFatEvidenceAfterDebounceAsync( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache, + CancellationTokenSource owner) + { + try + { + await Task.Delay(350, owner.Token); + await _nativeFatEvidenceHydrationService.SaveAsync(device, cache, owner.Token); + Trace.WriteLine( + $"[FAT P2] sparse evidence persisted asynchronously; ied={device.Name}; deviceId={device.DeviceId}; dispatcherBlocked=false."); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[FAT P2] sparse evidence persistence failed for {device.Name}: {ex.Message}"); + } + finally + { + if (_nativeFatEvidencePersistCtsByIed.TryGetValue(device.DeviceId, out var current) && + ReferenceEquals(current, owner)) + { + _nativeFatEvidencePersistCtsByIed.Remove(device.DeviceId); + owner.Dispose(); + } + } + } + private void RefreshNativeFatEvidenceCells(Iec61850MonitorPoint point) { if (_nativeFatCanonicalGrid == null) @@ -324,6 +540,15 @@ private void RefreshNativeFatEvidenceCells(Iec61850MonitorPoint point) } } + private void RefreshAllVisibleNativeFatEvidenceCells() + { + if (_nativeFatCanonicalGrid == null) + return; + + foreach (var point in _nativeFatCanonicalGrid.Items.OfType()) + RefreshNativeFatEvidenceCells(point); + } + private void SaveNativeFatSessionState() { if (_nativeFatCanonicalGrid == null || string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) @@ -381,7 +606,22 @@ private string ReadNativeFatEvidence(Iec61850MonitorPoint point, NativeFatEviden return string.Empty; } - return NativeFatCanonicalEvidenceOverlay.Read(cache, point, field); + var evidence = NativeFatCanonicalEvidenceOverlay.Read(cache, point, field); + if (!string.IsNullOrWhiteSpace(evidence)) + return evidence; + + return cache.IsEvidenceHydrating + ? NativeFatEvidenceLoadingPresentation.RollingDots(_nativeFatEvidenceClockPhase) + : string.Empty; + } + + private void NativeFatCanonicalGrid_BeginningEdit(object? sender, DataGridBeginningEditEventArgs e) + { + if (e.Column is not NativeFatEvidenceColumn || string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + return; + + if (_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache) && cache.IsEvidenceHydrating) + e.Cancel = true; } private void NativeFatCanonicalGrid_CellEditEnding(object? sender, DataGridCellEditEndingEventArgs e) @@ -398,10 +638,26 @@ e.EditingElement is not TextBox editor || var cache = GetNativeFatSession(_nativeFatBoundIedKey); NativeFatCanonicalEvidenceOverlay.Write(cache, point, evidenceColumn.Field, editor.Text); cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + ScheduleNativeFatEvidencePersist(_nativeFatBoundIedKey); } private void DisposeNativeFatArmCoordinator() { + CancelNativeFatEvidenceHydration(resetOldState: false); + if (_nativeFatEvidenceClock != null) + { + _nativeFatEvidenceClock.Tick -= NativeFatEvidenceClock_Tick; + _nativeFatEvidenceClock.Stop(); + _nativeFatEvidenceClock = null; + } + + foreach (var cts in _nativeFatEvidencePersistCtsByIed.Values) + { + cts.Cancel(); + cts.Dispose(); + } + _nativeFatEvidencePersistCtsByIed.Clear(); + if (_nativeFatArmEventsHooked) { _nativeFatArmCoordinator.EvidenceChanged -= NativeFatArmCoordinator_EvidenceChanged; @@ -409,9 +665,16 @@ private void DisposeNativeFatArmCoordinator() } _nativeFatArmCoordinator.Dispose(); + _nativeFatEvidenceHydrationService.Dispose(); if (_nativeFatStartButton != null) _nativeFatStartButton.Click -= NativeFatStartButton_Click; _nativeFatStartButton = null; + + if (_nativeFatCanonicalGrid != null) + { + _nativeFatCanonicalGrid.CellEditEnding -= NativeFatCanonicalGrid_CellEditEnding; + _nativeFatCanonicalGrid.BeginningEdit -= NativeFatCanonicalGrid_BeginningEdit; + } } private sealed class NativeFatEvidenceColumn : DataGridColumn From e1c96ad4b6f7388a8c41bdc3eb1e23462243d760 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:05:37 +0700 Subject: [PATCH 034/158] Add P2 async evidence hydration regression coverage --- .../NativeFatP2EvidenceHydrationTests.cs | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs diff --git a/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs b/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs new file mode 100644 index 000000000..05382a8db --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs @@ -0,0 +1,208 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP2EvidenceHydrationTests +{ + [Fact] + public async Task PersistThenHydrate_RestoresOnlySparseEvidenceForCanonicalRows() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var device = Device(); + var first = Point(device.DeviceId, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + var second = Point(device.DeviceId, "Trip", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); + device.Points.Add(first); + device.Points.Add(second); + + var saved = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(saved, first, NativeFatEvidenceField.Value1, "Open [01]"); + NativeFatCanonicalEvidenceOverlay.Write(saved, first, NativeFatEvidenceField.Value2, "Closed [10]"); + NativeFatCanonicalEvidenceOverlay.Write(saved, first, NativeFatEvidenceField.Result, "PASS"); + await service.SaveAsync(device, saved); + + var result = await service.HydrateAsync(device); + var restored = new NativeFatIedSessionCacheState(); + var merged = NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, result.EvidenceByRow); + + Assert.True(result.Succeeded); + Assert.True(result.SnapshotFound); + Assert.Equal(1, result.LoadedRows); + Assert.Equal(1, merged); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.Read(restored, first, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.Read(restored, first, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.Read(restored, first, NativeFatEvidenceField.Result)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(restored, second, NativeFatEvidenceField.Value1)); + Assert.Equal(2, device.Points.Count); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public async Task HydrationMerge_NeverOverwritesEvidenceCapturedAfterHydrationStarted() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var device = Device(); + var point = Point(device.DeviceId, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + device.Points.Add(point); + + var persisted = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(persisted, point, NativeFatEvidenceField.Value1, "OLD-V1"); + NativeFatCanonicalEvidenceOverlay.Write(persisted, point, NativeFatEvidenceField.Value2, "OLD-V2"); + NativeFatCanonicalEvidenceOverlay.Write(persisted, point, NativeFatEvidenceField.Result, "PASS"); + await service.SaveAsync(device, persisted); + + var result = await service.HydrateAsync(device); + var live = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(live, point, NativeFatEvidenceField.Value1, "NEW-LIVE-V1"); + + NativeFatCanonicalEvidenceOverlay.MergeMissing(live, result.EvidenceByRow); + + Assert.Equal("NEW-LIVE-V1", NativeFatCanonicalEvidenceOverlay.Read(live, point, NativeFatEvidenceField.Value1)); + Assert.Equal("OLD-V2", NativeFatCanonicalEvidenceOverlay.Read(live, point, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.Read(live, point, NativeFatEvidenceField.Result)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public async Task MissingSnapshot_ResolvesAsEmptyWithoutInventingEvidence() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var device = Device(); + device.Points.Add(Point(device.DeviceId, "Trip", "AA1E1F06R4LD0/GGIO1.Ind1.stVal")); + + var result = await service.HydrateAsync(device); + + Assert.True(result.Succeeded); + Assert.False(result.SnapshotFound); + Assert.Empty(result.EvidenceByRow); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData(0, "·")] + [InlineData(1, "··")] + [InlineData(2, "···")] + [InlineData(3, "·")] + public void RollingDots_UsesOneSharedPhase(int phase, string expected) + => Assert.Equal(expected, NativeFatEvidenceLoadingPresentation.RollingDots(phase)); + + [Fact] + public void P2_GridBindsCanonicalRowsBeforeStartingEvidenceHydration_AndUsesOneClock() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var serviceSource = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatEvidenceHydrationService.cs")); + + var bind = gridSource.IndexOf("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", StringComparison.Ordinal); + var hydrate = gridSource.IndexOf("BeginNativeFatEvidenceHydration(device);", bind, StringComparison.Ordinal); + Assert.True(bind >= 0); + Assert.True(hydrate > bind, "Canonical Engineering rows must be visible before evidence hydration begins."); + + Assert.Contains("DispatcherTimer? _nativeFatEvidenceClock", gridSource, StringComparison.Ordinal); + Assert.Equal(1, CountOccurrences(gridSource, "new DispatcherTimer")); + Assert.Contains("NativeFatEvidenceLoadingPresentation.RollingDots", gridSource, StringComparison.Ordinal); + Assert.Contains("cache.IsEvidenceHydrating", gridSource, StringComparison.Ordinal); + + Assert.DoesNotContain("PrepareIoTestIedForFatAsync", serviceSource, StringComparison.Ordinal); + Assert.DoesNotContain("OpenDescribedSourcesAsync", serviceSource, StringComparison.Ordinal); + Assert.DoesNotContain("IoFatEngineeringWorkspaceProjectionService", serviceSource, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", serviceSource, StringComparison.Ordinal); + Assert.DoesNotContain("StartMonitoringAsync", serviceSource, StringComparison.Ordinal); + } + + private static int CountOccurrences(string source, string token) + { + var count = 0; + var offset = 0; + while ((offset = source.IndexOf(token, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += token.Length; + } + return count; + } + + private static Iec61850MonitorDevice Device() + => new() + { + DeviceId = "dev-aa1e1f06r4", + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point(string deviceId, string name, string reference) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = name, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Status = "Live", + Value = "False" + }; + + private static string TempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "arsas-native-fat-p2-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + } + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From c363caf78a4b5d905f8735032c9631af84cb386b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:09:00 +0700 Subject: [PATCH 035/158] Fix P2 rolling dots switch expression --- Services/IoTesting/NativeFatEvidenceHydrationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/IoTesting/NativeFatEvidenceHydrationService.cs b/Services/IoTesting/NativeFatEvidenceHydrationService.cs index e2685f956..551d82b12 100644 --- a/Services/IoTesting/NativeFatEvidenceHydrationService.cs +++ b/Services/IoTesting/NativeFatEvidenceHydrationService.cs @@ -260,7 +260,7 @@ public static class NativeFatEvidenceLoadingPresentation /// No cell owns a timer or animation object. /// public static string RollingDots(int phase) - => Math.Abs(phase) % 3 switch + => (Math.Abs(phase) % 3) switch { 0 => "·", 1 => "··", From bb0bf7764b04ecd05c89f2f339169dbc0e672345 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:11:35 +0700 Subject: [PATCH 036/158] Hydrate P2 from existing FAT snapshots without bootstrap --- .../NativeFatEvidenceHydrationService.cs | 415 +++++++++++++++++- 1 file changed, 402 insertions(+), 13 deletions(-) diff --git a/Services/IoTesting/NativeFatEvidenceHydrationService.cs b/Services/IoTesting/NativeFatEvidenceHydrationService.cs index 551d82b12..c92827473 100644 --- a/Services/IoTesting/NativeFatEvidenceHydrationService.cs +++ b/Services/IoTesting/NativeFatEvidenceHydrationService.cs @@ -19,6 +19,8 @@ public sealed record NativeFatEvidenceHydrationResult( /// P2 local evidence persistence/hydration for the native Engineering FAT surface. /// This service owns only sparse Value 1 / Value 2 / Result data. It never touches /// Engineering acquisition, SCL, reports, MMS sessions, polling cadence, or canonical rows. +/// Existing IO FAT snapshots are read as passive evidence provenance only; opening FAT never +/// opens/restores their workspace model or starts any legacy bootstrap path. /// public sealed class NativeFatEvidenceHydrationService : IDisposable { @@ -30,18 +32,35 @@ public sealed class NativeFatEvidenceHydrationService : IDisposable WriteIndented = false }; + private static readonly HashSet FunctionalConstraintTokens = new( + new[] { "st", "mx", "sp", "sv", "cf", "dc", "sg", "se", "sr", "or", "bl", "ex", "co", "us", "ms", "rp", "br", "lg", "go", "gs" }, + StringComparer.OrdinalIgnoreCase); + private readonly string _rootDirectory; + private readonly string _legacyProjectsRoot; private readonly SemaphoreSlim _ioGate = new(1, 1); private bool _disposed; - public NativeFatEvidenceHydrationService(string? rootDirectory = null) + public NativeFatEvidenceHydrationService( + string? rootDirectory = null, + string? legacyProjectsRoot = null) { - _rootDirectory = string.IsNullOrWhiteSpace(rootDirectory) + var usingDefaultRoot = string.IsNullOrWhiteSpace(rootDirectory); + _rootDirectory = usingDefaultRoot ? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ARSAS", "Native FAT Evidence") - : Path.GetFullPath(rootDirectory); + : Path.GetFullPath(rootDirectory!); + + _legacyProjectsRoot = !string.IsNullOrWhiteSpace(legacyProjectsRoot) + ? Path.GetFullPath(legacyProjectsRoot) + : usingDefaultRoot + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "IO Testing Projects") + : string.Empty; } public async Task HydrateAsync( @@ -54,9 +73,7 @@ public async Task HydrateAsync( // Snapshot canonical identity synchronously on the caller/UI thread. Everything // after this point is file IO / JSON work and does not enumerate the live collection. var identity = CaptureIdentity(device); - var canonicalKeys = device.Points - .Select(NativeFatCanonicalEvidenceOverlay.BuildRowKey) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + var canonical = CaptureCanonicalIdentity(device); var stopwatch = Stopwatch.StartNew(); var path = SnapshotPath(identity.DeviceId); @@ -66,7 +83,25 @@ public async Task HydrateAsync( cancellationToken.ThrowIfCancellationRequested(); if (!File.Exists(path)) { + // First native launch may still have evidence in the pre-P1 persistent + // project snapshot. Read it passively and map only uniquely covered rows. + var legacy = await TryHydrateLegacySnapshotAsync( + identity, + canonical, + cancellationToken).ConfigureAwait(false); stopwatch.Stop(); + if (legacy != null) + { + return new NativeFatEvidenceHydrationResult( + true, + true, + legacy.EvidenceByRow.Count, + legacy.IgnoredRows, + stopwatch.ElapsedMilliseconds, + $"Restored {legacy.EvidenceByRow.Count} legacy FAT evidence row(s) for {identity.DeviceName} in {stopwatch.ElapsedMilliseconds} ms without opening the legacy workspace.", + legacy.EvidenceByRow); + } + return EmptyResult( stopwatch.ElapsedMilliseconds, $"No saved FAT evidence exists yet for {identity.DeviceName}."); @@ -86,20 +121,15 @@ public async Task HydrateAsync( foreach (var pair in document.EvidenceByRow ?? new Dictionary()) { cancellationToken.ThrowIfCancellationRequested(); - if (!canonicalKeys.Contains(pair.Key)) + if (!canonical.RowKeys.Contains(pair.Key)) { ignored++; continue; } var source = pair.Value; - if (source == null || - (string.IsNullOrWhiteSpace(source.Value1) && - string.IsNullOrWhiteSpace(source.Value2) && - string.IsNullOrWhiteSpace(source.Result))) - { + if (source == null || IsEmpty(source)) continue; - } loaded[pair.Key] = Clone(source); } @@ -207,6 +237,352 @@ public void Dispose() _ioGate.Dispose(); } + private async Task TryHydrateLegacySnapshotAsync( + NativeFatDeviceIdentity identity, + CanonicalEvidenceIdentity canonical, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_legacyProjectsRoot) || !Directory.Exists(_legacyProjectsRoot)) + return null; + + string[] candidates; + try + { + candidates = Directory + .EnumerateFiles(_legacyProjectsRoot, "project.snapshot.json", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .ToArray(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var bytes = await File.ReadAllBytesAsync(candidate, cancellationToken).ConfigureAwait(false); + using var document = JsonDocument.Parse(bytes); + if (!TryGetProjectIeds(document.RootElement, out var ieds) || + !TryFindLegacyIed(ieds, identity, out var legacyIed)) + { + continue; + } + + // Newest matching snapshot is authoritative, including an intentionally + // empty evidence set. Never resurrect older evidence after a later clear. + return ExtractLegacyEvidence(legacyIed, canonical); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + // A damaged/unreadable unrelated historical snapshot must not block FAT. + continue; + } + } + + return null; + } + + private static bool TryGetProjectIeds(JsonElement root, out JsonElement ieds) + { + ieds = default; + if (!root.TryGetProperty("project", out var project) || + !project.TryGetProperty("ieds", out ieds) || + ieds.ValueKind != JsonValueKind.Array) + { + return false; + } + return true; + } + + private static bool TryFindLegacyIed( + JsonElement ieds, + NativeFatDeviceIdentity identity, + out JsonElement legacyIed) + { + legacyIed = default; + JsonElement? nameAndIp = null; + JsonElement? uniqueIp = null; + var ipMatches = 0; + + foreach (var ied in ieds.EnumerateArray()) + { + var liveDeviceId = GetString(ied, "liveDeviceId"); + if (!string.IsNullOrWhiteSpace(liveDeviceId) && + liveDeviceId.Equals(identity.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + legacyIed = ied; + return true; + } + + var ip = GetString(ied, "ipAddress"); + if (!ip.Equals(identity.IpAddress, StringComparison.OrdinalIgnoreCase)) + continue; + + ipMatches++; + uniqueIp = ied; + if (GetString(ied, "iedName").Equals(identity.DeviceName, StringComparison.OrdinalIgnoreCase)) + nameAndIp = ied; + } + + if (nameAndIp.HasValue) + { + legacyIed = nameAndIp.Value; + return true; + } + + if (ipMatches == 1 && uniqueIp.HasValue) + { + legacyIed = uniqueIp.Value; + return true; + } + + return false; + } + + private static LegacyHydration ExtractLegacyEvidence( + JsonElement legacyIed, + CanonicalEvidenceIdentity canonical) + { + var loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ignored = 0; + if (!legacyIed.TryGetProperty("testPoints", out var testPoints) || + testPoints.ValueKind != JsonValueKind.Array) + { + return new LegacyHydration(loaded, ignored); + } + + foreach (var point in testPoints.EnumerateArray()) + { + if (!point.TryGetProperty("runtime", out var runtime) || runtime.ValueKind != JsonValueKind.Object) + continue; + + var value1 = GetEvidenceRaw(runtime, "value1Evidence"); + if (string.IsNullOrWhiteSpace(value1)) + value1 = GetEvidenceRaw(runtime, "onEvidence"); + var value2 = GetEvidenceRaw(runtime, "value2Evidence"); + if (string.IsNullOrWhiteSpace(value2)) + value2 = GetEvidenceRaw(runtime, "offEvidence"); + var result = ReadLegacyResult(point, runtime, value1, value2); + + if (string.IsNullOrWhiteSpace(value1) && + string.IsNullOrWhiteSpace(value2) && + string.IsNullOrWhiteSpace(result)) + { + continue; + } + + var rowKey = ResolveLegacyRowKey(point, canonical); + if (string.IsNullOrWhiteSpace(rowKey)) + { + ignored++; + continue; + } + + if (!loaded.TryGetValue(rowKey, out var slot)) + { + slot = new NativeFatEvidenceSlotState(); + loaded[rowKey] = slot; + } + + if (string.IsNullOrWhiteSpace(slot.Value1) && !string.IsNullOrWhiteSpace(value1)) + slot.Value1 = value1; + if (string.IsNullOrWhiteSpace(slot.Value2) && !string.IsNullOrWhiteSpace(value2)) + slot.Value2 = value2; + if (string.IsNullOrWhiteSpace(slot.Result) && !string.IsNullOrWhiteSpace(result)) + slot.Result = result; + } + + return new LegacyHydration(loaded, ignored); + } + + private static string? ResolveLegacyRowKey( + JsonElement point, + CanonicalEvidenceIdentity canonical) + { + var matches = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var property in new[] + { + "sourceIecReference", + "eventLogSearchReference", + "reportDisplayReference", + "objectReference", + "signalAddress" + }) + { + var reference = GetString(point, property); + foreach (var alias in ReferenceAliases(reference)) + { + if (canonical.AliasToRowKey.TryGetValue(alias, out var rowKey) && + !string.IsNullOrWhiteSpace(rowKey)) + { + matches.Add(rowKey); + } + } + } + + return matches.Count == 1 ? matches.First() : null; + } + + private static string ReadLegacyResult( + JsonElement point, + JsonElement runtime, + string value1, + string value2) + { + var state = ReadStateOrdinal(runtime); + if (state == 5) return "PASS"; + if (state == 6) return "REVIEW"; + if (state == 7) return "FAILED"; + + var reviewStatus = GetString(point, "reviewStatus").Trim(); + if (reviewStatus.Equals("PASS", StringComparison.OrdinalIgnoreCase) || + reviewStatus.Equals("REVIEW", StringComparison.OrdinalIgnoreCase) || + reviewStatus.Equals("FAILED", StringComparison.OrdinalIgnoreCase)) + { + return reviewStatus.ToUpperInvariant(); + } + + var captureMode = ReadCaptureModeOrdinal(point); + return captureMode == 1 && + !string.IsNullOrWhiteSpace(value1) && + !string.IsNullOrWhiteSpace(value2) + ? "COMPLETE" + : string.Empty; + } + + private static int ReadStateOrdinal(JsonElement runtime) + { + if (!runtime.TryGetProperty("state", out var state)) + return -1; + if (state.ValueKind == JsonValueKind.Number && state.TryGetInt32(out var ordinal)) + return ordinal; + if (state.ValueKind != JsonValueKind.String) + return -1; + + return (state.GetString() ?? string.Empty).Trim() switch + { + "Passed" => 5, + "Review" => 6, + "Failed" => 7, + _ => -1 + }; + } + + private static int ReadCaptureModeOrdinal(JsonElement point) + { + if (!point.TryGetProperty("captureMode", out var mode)) + return -1; + if (mode.ValueKind == JsonValueKind.Number && mode.TryGetInt32(out var ordinal)) + return ordinal; + if (mode.ValueKind == JsonValueKind.String && + string.Equals(mode.GetString(), "OperatorSnapshot", StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + return 0; + } + + private static string GetEvidenceRaw(JsonElement runtime, string property) + { + if (!runtime.TryGetProperty(property, out var evidence) || + evidence.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined || + evidence.ValueKind != JsonValueKind.Object) + { + return string.Empty; + } + + return GetString(evidence, "rawValue").Trim(); + } + + private static string GetString(JsonElement element, string property) + { + if (!element.TryGetProperty(property, out var value)) + return string.Empty; + return value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + ? string.Empty + : value.ToString(); + } + + private static CanonicalEvidenceIdentity CaptureCanonicalIdentity(Iec61850MonitorDevice device) + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var point in device.Points) + { + var key = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + keys.Add(key); + AddAliases(aliases, point.IecReference, key); + AddAliases(aliases, point.IecTelegram, key); + } + return new CanonicalEvidenceIdentity(keys, aliases); + } + + private static void AddAliases( + Dictionary aliases, + string? reference, + string rowKey) + { + foreach (var alias in ReferenceAliases(reference)) + { + if (aliases.TryGetValue(alias, out var existing)) + { + if (!string.Equals(existing, rowKey, StringComparison.OrdinalIgnoreCase)) + aliases[alias] = null; + } + else + { + aliases[alias] = rowKey; + } + } + } + + private static IEnumerable ReferenceAliases(string? reference) + { + var normalized = NormalizeReference(reference); + if (normalized.Length == 0) + yield break; + + yield return normalized; + var withoutFc = RemoveFunctionalConstraint(normalized); + if (!withoutFc.Equals(normalized, StringComparison.OrdinalIgnoreCase)) + yield return withoutFc; + } + + private static string NormalizeReference(string? reference) + { + var text = (reference ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".", StringComparison.Ordinal) + .ToLowerInvariant(); + while (text.Contains("..", StringComparison.Ordinal)) + text = text.Replace("..", ".", StringComparison.Ordinal); + return text.Trim('.'); + } + + private static string RemoveFunctionalConstraint(string normalized) + { + var slash = normalized.IndexOf('/'); + if (slash < 0 || slash >= normalized.Length - 1) + return normalized; + + var domain = normalized[..(slash + 1)]; + var path = normalized[(slash + 1)..].Split('.', StringSplitOptions.RemoveEmptyEntries); + if (path.Length < 3 || !FunctionalConstraintTokens.Contains(path[1])) + return normalized; + + return domain + string.Join('.', path.Where((_, index) => index != 1)); + } + private static NativeFatDeviceIdentity CaptureIdentity(Iec61850MonitorDevice device) => new( device.DeviceId, @@ -221,6 +597,11 @@ private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState sourc Result = source.Result }; + private static bool IsEmpty(NativeFatEvidenceSlotState slot) + => string.IsNullOrWhiteSpace(slot.Value1) && + string.IsNullOrWhiteSpace(slot.Value2) && + string.IsNullOrWhiteSpace(slot.Result); + private static NativeFatEvidenceHydrationResult EmptyResult(long elapsedMilliseconds, string message) => new( true, @@ -241,6 +622,14 @@ private sealed record NativeFatDeviceIdentity( string DeviceName, string IpAddress); + private sealed record CanonicalEvidenceIdentity( + HashSet RowKeys, + Dictionary AliasToRowKey); + + private sealed record LegacyHydration( + IReadOnlyDictionary EvidenceByRow, + int IgnoredRows); + private sealed class NativeFatEvidenceDocument { public string Schema { get; set; } = SnapshotSchema; From 495fde74ee0d7324078ccfdd25182bc3c1d90367 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:12:13 +0700 Subject: [PATCH 037/158] Test P2 passive legacy evidence hydration --- .../NativeFatP2EvidenceHydrationTests.cs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs b/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs index 05382a8db..2e193d9a0 100644 --- a/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs +++ b/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ArIED61850Tester.Models; using ArIED61850Tester.Services.IoTesting; @@ -77,6 +78,78 @@ public async Task HydrationMerge_NeverOverwritesEvidenceCapturedAfterHydrationSt } } + [Fact] + public async Task FirstNativeOpen_PassivelyHydratesUniqueLegacyEvidenceWithoutOpeningLegacyWorkspace() + { + var nativeRoot = TempRoot(); + var legacyRoot = TempRoot(); + try + { + var device = Device(); + var point = Point(device.DeviceId, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + device.Points.Add(point); + + var legacyProject = new + { + project = new + { + ieds = new[] + { + new + { + iedName = "AA1E1F06R4", + ipAddress = "192.168.81.103", + liveDeviceId = device.DeviceId, + testPoints = new[] + { + new + { + testPointId = "scl-manual-7496d038be4fdc18e340", + sourceIecReference = "LD0/XCBR1$ST$Pos$stVal", + eventLogSearchReference = "", + reportDisplayReference = "", + objectReference = "LD0/XCBR1$ST$Pos$stVal", + signalAddress = "", + captureMode = 0, + reviewStatus = "", + runtime = new + { + state = 5, + value1Evidence = new { rawValue = "Open [01]" }, + value2Evidence = new { rawValue = "Closed [10]" } + } + } + } + } + } + } + }; + var legacyDirectory = Path.Combine(legacyRoot, "legacy-project"); + Directory.CreateDirectory(legacyDirectory); + await File.WriteAllTextAsync( + Path.Combine(legacyDirectory, "project.snapshot.json"), + JsonSerializer.Serialize(legacyProject)); + + using var service = new NativeFatEvidenceHydrationService(nativeRoot, legacyRoot); + var result = await service.HydrateAsync(device); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, result.EvidenceByRow); + + Assert.True(result.Succeeded); + Assert.True(result.SnapshotFound); + Assert.Equal(1, result.LoadedRows); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.Read(restored, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.Read(restored, point, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.Read(restored, point, NativeFatEvidenceField.Result)); + Assert.Single(device.Points); + } + finally + { + TryDelete(nativeRoot); + TryDelete(legacyRoot); + } + } + [Fact] public async Task MissingSnapshot_ResolvesAsEmptyWithoutInventingEvidence() { From f877d9c8952d937d9ba130c7c387eb4e51d52907 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:28:23 +0700 Subject: [PATCH 038/158] FAT P3: add immutable native preview snapshot --- .../NativeFatPrintPreviewSnapshot.cs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 Services/IoTesting/NativeFatPrintPreviewSnapshot.cs diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs new file mode 100644 index 000000000..8b9ba253d --- /dev/null +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -0,0 +1,106 @@ +using System.Collections.ObjectModel; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatPrintPreviewRow( + string Signal, + string IecReference, + string Type, + string LiveValue, + string Value1, + string Value2, + string Status, + string Result); + +/// +/// P3 immutable, selected-IED-only report input for native Engineering FAT. +/// Capture copies primitive display values from the canonical Engineering rows and +/// sparse evidence overlay. No live row/evidence object is retained after Capture returns. +/// +public sealed class NativeFatPrintPreviewSnapshot +{ + private readonly ReadOnlyCollection _rows; + + private NativeFatPrintPreviewSnapshot( + DateTimeOffset capturedAt, + string deviceId, + string iedName, + string ipAddress, + int port, + IReadOnlyCollection rows) + { + CapturedAt = capturedAt; + DeviceId = deviceId; + IedName = iedName; + IpAddress = ipAddress; + Port = port; + _rows = Array.AsReadOnly(rows.ToArray()); + } + + public DateTimeOffset CapturedAt { get; } + public string DeviceId { get; } + public string IedName { get; } + public string IpAddress { get; } + public int Port { get; } + public IReadOnlyList Rows => _rows; + public int CompleteCount => _rows.Count(row => + row.Status.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase)); + public string ProgressText => $"{CompleteCount}/{_rows.Count} complete"; + + public static NativeFatPrintPreviewSnapshot Capture( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(cache); + + // Materialize in the current canonical Engineering row order. Every value below + // is copied now; the preview never binds back to device.Points or EvidenceByRow. + var rows = device.Points.Select(point => + { + var value1 = NativeFatCanonicalEvidenceOverlay.Read( + cache, + point, + NativeFatEvidenceField.Value1).Trim(); + var value2 = NativeFatCanonicalEvidenceOverlay.Read( + cache, + point, + NativeFatEvidenceField.Value2).Trim(); + var result = NativeFatCanonicalEvidenceOverlay.Read( + cache, + point, + NativeFatEvidenceField.Result).Trim(); + + var status = !string.IsNullOrWhiteSpace(value1) && !string.IsNullOrWhiteSpace(value2) + ? "COMPLETE" + : !string.IsNullOrWhiteSpace(value1) + ? "WAITING V2" + : "WAITING V1"; + + return new NativeFatPrintPreviewRow( + Copy(point.SignalName), + Copy(point.IecReference), + Copy(point.IecDataType), + Display(point.DisplayValue), + Display(value1), + Display(value2), + status, + Display(result)); + }).ToArray(); + + return new NativeFatPrintPreviewSnapshot( + DateTimeOffset.Now, + Copy(device.DeviceId), + Copy(device.Name), + Copy(device.IpAddress), + device.Port, + rows); + } + + private static string Copy(string? value) + => value?.Trim() ?? string.Empty; + + private static string Display(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); +} From 124e54a6bf7a3d8ca3c5c3bcb5010ed0c1bbd9a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:28:46 +0700 Subject: [PATCH 039/158] FAT P3: add lazy native print preview renderer --- MainWindow.NativeFatPrintPreview.cs | 176 ++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 MainWindow.NativeFatPrintPreview.cs diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs new file mode 100644 index 000000000..eb1773d58 --- /dev/null +++ b/MainWindow.NativeFatPrintPreview.cs @@ -0,0 +1,176 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private const string NativeFatPrintPreviewTitle = "IEC 61850 FAT Evidence Report"; + private const string NativeFatPrintPreviewSubtitle = "Static DataSet verification · generic Value 1 / Value 2 evidence · source identity preserved"; + + /// + /// P3 renderer. This method receives only an immutable selected-IED snapshot and never + /// binds to SelectedDevice, canonical live rows, sparse evidence, or acquisition state. + /// The window therefore remains frozen even while Engineering continues monitoring. + /// + private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + var preview = new Window + { + Owner = this, + Title = NativeFatPrintPreviewTitle, + Width = 1180, + Height = 820, + MinWidth = 900, + MinHeight = 620, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Background = new SolidColorBrush(Color.FromRgb(241, 245, 249)) + }; + + var root = new Grid { Margin = new Thickness(18) }; + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var page = new Border + { + Background = Brushes.White, + BorderBrush = new SolidColorBrush(Color.FromRgb(218, 226, 238)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(30, 26, 30, 24), + Effect = null + }; + + var pageGrid = new Grid(); + pageGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + pageGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(18) }); + pageGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + pageGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(18) }); + pageGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var heading = new StackPanel(); + heading.Children.Add(new TextBlock + { + Text = NativeFatPrintPreviewTitle, + FontSize = 24, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(23, 32, 51)) + }); + heading.Children.Add(new TextBlock + { + Text = NativeFatPrintPreviewSubtitle, + FontSize = 12, + Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)), + Margin = new Thickness(0, 5, 0, 0) + }); + pageGrid.Children.Add(heading); + + var summary = new Grid + { + Background = new SolidColorBrush(Color.FromRgb(248, 250, 252)), + Margin = new Thickness(0) + }; + summary.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + summary.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var selectedIed = new StackPanel { Margin = new Thickness(14, 10, 14, 10) }; + selectedIed.Children.Add(new TextBlock + { + Text = "SELECTED IED", + FontSize = 9.5, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)) + }); + selectedIed.Children.Add(new TextBlock + { + Text = $"{snapshot.IedName} · {snapshot.IpAddress}:{snapshot.Port}", + FontSize = 13, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(23, 32, 51)), + Margin = new Thickness(0, 3, 0, 0) + }); + summary.Children.Add(selectedIed); + + var progress = new StackPanel + { + Margin = new Thickness(18, 10, 14, 10), + HorizontalAlignment = HorizontalAlignment.Right + }; + progress.Children.Add(new TextBlock + { + Text = "PROGRESS", + FontSize = 9.5, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)), + HorizontalAlignment = HorizontalAlignment.Right + }); + progress.Children.Add(new TextBlock + { + Text = snapshot.ProgressText, + FontSize = 13, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(36, 88, 184)), + Margin = new Thickness(0, 3, 0, 0), + HorizontalAlignment = HorizontalAlignment.Right + }); + Grid.SetColumn(progress, 1); + summary.Children.Add(progress); + Grid.SetRow(summary, 2); + pageGrid.Children.Add(summary); + + var grid = new DataGrid + { + ItemsSource = snapshot.Rows, + AutoGenerateColumns = false, + CanUserAddRows = false, + CanUserDeleteRows = false, + CanUserReorderColumns = false, + IsReadOnly = true, + SelectionUnit = DataGridSelectionUnit.FullRow, + EnableRowVirtualization = true, + EnableColumnVirtualization = true, + HeadersVisibility = DataGridHeadersVisibility.Column, + RowHeight = 32, + ColumnHeaderHeight = 34, + GridLinesVisibility = DataGridGridLinesVisibility.Horizontal, + HorizontalGridLinesBrush = new SolidColorBrush(Color.FromRgb(229, 234, 242)), + VerticalGridLinesBrush = Brushes.Transparent, + BorderBrush = new SolidColorBrush(Color.FromRgb(218, 226, 238)), + BorderThickness = new Thickness(1), + Background = Brushes.White + }; + if (TryFindResource("ModernDataGrid") is Style modernDataGrid) + grid.Style = modernDataGrid; + + AddPreviewColumn(grid, "Signal", nameof(NativeFatPrintPreviewRow.Signal), 170); + AddPreviewColumn(grid, "IEC 61850 reference", nameof(NativeFatPrintPreviewRow.IecReference), 285); + AddPreviewColumn(grid, "Type", nameof(NativeFatPrintPreviewRow.Type), 90); + AddPreviewColumn(grid, "Live value", nameof(NativeFatPrintPreviewRow.LiveValue), 115); + AddPreviewColumn(grid, "Value 1", nameof(NativeFatPrintPreviewRow.Value1), 115); + AddPreviewColumn(grid, "Value 2", nameof(NativeFatPrintPreviewRow.Value2), 115); + AddPreviewColumn(grid, "Status", nameof(NativeFatPrintPreviewRow.Status), 110); + AddPreviewColumn(grid, "Result", nameof(NativeFatPrintPreviewRow.Result), 105); + + Grid.SetRow(grid, 4); + pageGrid.Children.Add(grid); + page.Child = pageGrid; + root.Children.Add(page); + preview.Content = root; + preview.Show(); + } + + private static void AddPreviewColumn(DataGrid grid, string header, string path, double width) + { + grid.Columns.Add(new DataGridTextColumn + { + Header = header, + Binding = new Binding(path) { Mode = BindingMode.OneWay }, + Width = new DataGridLength(width), + IsReadOnly = true + }); + } +} From 680631af8b60f7d89b9878cccb24efc5eaf0f5ae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:29:17 +0700 Subject: [PATCH 040/158] FAT P3: wire lazy immutable preview capture --- MainWindow.NativeFatPrintPreview.cs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs index eb1773d58..eacc53c52 100644 --- a/MainWindow.NativeFatPrintPreview.cs +++ b/MainWindow.NativeFatPrintPreview.cs @@ -10,6 +10,30 @@ public partial class MainWindow { private const string NativeFatPrintPreviewTitle = "IEC 61850 FAT Evidence Report"; private const string NativeFatPrintPreviewSubtitle = "Static DataSet verification · generic Value 1 / Value 2 evidence · source identity preserved"; + private Button? _nativeFatPrintPreviewButton; + + private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) + { + var device = SelectedDevice; + if (device == null || device.Points.Count == 0) + { + SetStatus("FAT · select an Engineering IED with canonical rows before opening Print Preview"); + return; + } + + // Commit the current operator evidence cell before copying the report snapshot. + // Capture is intentionally invoked only from this click path: normal FAT navigation, + // row binding, hydration, and acquisition never build a hidden report. + _nativeFatCanonicalGrid?.CommitEdit(DataGridEditingUnit.Cell, true); + _nativeFatCanonicalGrid?.CommitEdit(DataGridEditingUnit.Row, true); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture( + device, + GetNativeFatSession(device.DeviceId)); + ShowNativeFatPrintPreview(snapshot); + SetStatus( + $"FAT · Print Preview captured {snapshot.Rows.Count} immutable canonical row(s) for {snapshot.IedName}"); + } /// /// P3 renderer. This method receives only an immutable selected-IED snapshot and never From f1c74c41984ea14c09aebbf6419c1e12ba665998 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:30:20 +0700 Subject: [PATCH 041/158] FAT P3: expose lazy selected-IED print preview --- MainWindow.NativeFatCanonicalGrid.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/MainWindow.NativeFatCanonicalGrid.cs b/MainWindow.NativeFatCanonicalGrid.cs index 9117377e3..e2f7ad558 100644 --- a/MainWindow.NativeFatCanonicalGrid.cs +++ b/MainWindow.NativeFatCanonicalGrid.cs @@ -37,6 +37,7 @@ public partial class MainWindow /// P1C reuses the Engineering grid visual authority and virtualization contract. /// P1D makes Start FAT an ARM-only operation over those already-live row objects. /// P2 hydrates only sparse evidence asynchronously; canonical rows and live Value never wait. + /// P3 builds no report until Print Preview is clicked, then renders an immutable selected-IED snapshot. /// private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = null) { @@ -105,6 +106,19 @@ private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = n }; actionPanel.Children.Add(_nativeFatRowCountText); + _nativeFatPrintPreviewButton = new Button + { + Content = "Print Preview", + MinWidth = 104, + Padding = new Thickness(12, 6, 12, 6), + Margin = new Thickness(0, 0, 8, 0), + Style = TryFindResource("SoftButton") as Style, + IsEnabled = false, + ToolTip = "Capture an immutable Print Preview for the selected Engineering IED only." + }; + _nativeFatPrintPreviewButton.Click += NativeFatPrintPreviewButton_Click; + actionPanel.Children.Add(_nativeFatPrintPreviewButton); + _nativeFatStartButton = new Button { Content = "Start FAT", @@ -243,6 +257,8 @@ private void BindNativeFatCanonicalRows() ? "FAT · select an Engineering IED" : $"FAT · {device.Name} · {device.IpAddress}:{device.Port}"; _nativeFatRowCountText!.Text = device == null ? "0 rows" : $"{device.Points.Count} rows"; + if (_nativeFatPrintPreviewButton != null) + _nativeFatPrintPreviewButton.IsEnabled = device?.Points.Count > 0; RestoreNativeFatSessionState(device); UpdateNativeFatArmUi(device); @@ -669,6 +685,9 @@ private void DisposeNativeFatArmCoordinator() if (_nativeFatStartButton != null) _nativeFatStartButton.Click -= NativeFatStartButton_Click; _nativeFatStartButton = null; + if (_nativeFatPrintPreviewButton != null) + _nativeFatPrintPreviewButton.Click -= NativeFatPrintPreviewButton_Click; + _nativeFatPrintPreviewButton = null; if (_nativeFatCanonicalGrid != null) { From 723e986dfa02cf34002588464baf559f62e3b11a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 07:30:47 +0700 Subject: [PATCH 042/158] FAT P3: add lazy immutable preview regressions --- .../NativeFatP3PrintPreviewTests.cs | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs diff --git a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs new file mode 100644 index 000000000..82bd0a1a2 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs @@ -0,0 +1,209 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP3PrintPreviewTests +{ + [Fact] + public void Capture_CopiesSelectedCanonicalRowsInCurrentOrderAndSparseEvidence() + { + var device = Device("dev-aa1e1f06r4", "AA1E1F06R4", "192.168.81.103"); + var breaker = Point(device.DeviceId, device.Name, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal", "Open [01]"); + var trip = Point(device.DeviceId, device.Name, "Trip", "AA1E1F06R4LD0/GGIO1.Ind1.stVal", "False"); + device.Points.Add(breaker); + device.Points.Add(trip); + + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(cache, breaker, NativeFatEvidenceField.Value1, "Open [01]"); + NativeFatCanonicalEvidenceOverlay.Write(cache, breaker, NativeFatEvidenceField.Value2, "Closed [10]"); + NativeFatCanonicalEvidenceOverlay.Write(cache, breaker, NativeFatEvidenceField.Result, "PASS"); + NativeFatCanonicalEvidenceOverlay.Write(cache, trip, NativeFatEvidenceField.Value1, "False"); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); + + Assert.Equal(device.DeviceId, snapshot.DeviceId); + Assert.Equal("AA1E1F06R4", snapshot.IedName); + Assert.Equal("192.168.81.103", snapshot.IpAddress); + Assert.Equal(102, snapshot.Port); + Assert.Equal(2, snapshot.Rows.Count); + Assert.Equal("Breaker", snapshot.Rows[0].Signal); + Assert.Equal("Trip", snapshot.Rows[1].Signal); + Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); + Assert.Equal("Open [01]", snapshot.Rows[0].Value1); + Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); + Assert.Equal("PASS", snapshot.Rows[0].Result); + Assert.Equal("COMPLETE", snapshot.Rows[0].Status); + Assert.Equal("WAITING V2", snapshot.Rows[1].Status); + Assert.Equal("1/2 complete", snapshot.ProgressText); + } + + [Fact] + public void Capture_RemainsImmutableWhenLiveRowsAndEvidenceChange() + { + var device = Device("dev-aa1e1f06r4", "AA1E1F06R4", "192.168.81.103"); + var point = Point(device.DeviceId, device.Name, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal", "Open [01]"); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "Open [01]"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, "Closed [10]"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "PASS"); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); + + point.Value = "Closed [10]"; + point.SignalName = "MUTATED"; + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "NEW-V1"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, "NEW-V2"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "REVIEW"); + + Assert.Single(snapshot.Rows); + Assert.Equal("Breaker", snapshot.Rows[0].Signal); + Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); + Assert.Equal("Open [01]", snapshot.Rows[0].Value1); + Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); + Assert.Equal("PASS", snapshot.Rows[0].Result); + Assert.Equal("COMPLETE", snapshot.Rows[0].Status); + } + + [Fact] + public void Capture_ContainsOnlyRequestedDevice() + { + var selected = Device("dev-aa1e1f06r4", "AA1E1F06R4", "192.168.81.103"); + selected.Points.Add(Point(selected.DeviceId, selected.Name, "Selected", "AA1E1F06R4LD0/GGIO1.Ind1.stVal", "True")); + + var other = Device("dev-other", "OTHER-IED", "192.168.81.104"); + other.Points.Add(Point(other.DeviceId, other.Name, "Other", "OTHERLD0/GGIO1.Ind1.stVal", "False")); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(selected, new NativeFatIedSessionCacheState()); + + Assert.Equal(selected.DeviceId, snapshot.DeviceId); + Assert.Equal(selected.Name, snapshot.IedName); + Assert.Single(snapshot.Rows); + Assert.Equal("Selected", snapshot.Rows[0].Signal); + Assert.DoesNotContain(snapshot.Rows, row => row.Signal == "Other"); + } + + [Fact] + public void P3_PreviewIsLazySelectedIedOnlyAndKeepsLegacyVisibleContract() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var previewSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var snapshotSource = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); + + var build = ExtractMethod(gridSource, "private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = null)"); + var bind = ExtractMethod(gridSource, "private void BindNativeFatCanonicalRows()"); + var click = ExtractMethod(previewSource, "private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e)"); + + Assert.Contains("Content = \"Print Preview\"", build, StringComparison.Ordinal); + Assert.DoesNotContain("NativeFatPrintPreviewSnapshot.Capture", build, StringComparison.Ordinal); + Assert.DoesNotContain("ShowNativeFatPrintPreview", build, StringComparison.Ordinal); + Assert.DoesNotContain("NativeFatPrintPreviewSnapshot.Capture", bind, StringComparison.Ordinal); + Assert.DoesNotContain("ShowNativeFatPrintPreview", bind, StringComparison.Ordinal); + Assert.Contains("NativeFatPrintPreviewSnapshot.Capture", click, StringComparison.Ordinal); + Assert.Contains("ShowNativeFatPrintPreview(snapshot)", click, StringComparison.Ordinal); + Assert.Contains("SelectedDevice", click, StringComparison.Ordinal); + + Assert.Contains("IEC 61850 FAT Evidence Report", previewSource, StringComparison.Ordinal); + Assert.Contains("Static DataSet verification · generic Value 1 / Value 2 evidence · source identity preserved", previewSource, StringComparison.Ordinal); + foreach (var header in new[] + { + "Signal", + "IEC 61850 reference", + "Type", + "Live value", + "Value 1", + "Value 2", + "Status", + "Result" + }) + { + Assert.Contains($"AddPreviewColumn(grid, \"{header}\"", previewSource, StringComparison.Ordinal); + } + + Assert.Contains("device.Points.Select", snapshotSource, StringComparison.Ordinal); + Assert.Contains("Array.AsReadOnly", snapshotSource, StringComparison.Ordinal); + Assert.DoesNotContain("Iec61850MonitorPoint Point", snapshotSource, StringComparison.Ordinal); + + foreach (var forbidden in new[] + { + "IoTestProject", + "PrepareIoTestIedForFatAsync", + "OpenDescribedSourcesAsync", + "IoFatEngineeringWorkspaceProjectionService", + "ConnectAndDiscoverAsync", + "StartMonitoringAsync" + }) + { + Assert.DoesNotContain(forbidden, previewSource, StringComparison.Ordinal); + Assert.DoesNotContain(forbidden, snapshotSource, StringComparison.Ordinal); + } + } + + private static Iec61850MonitorDevice Device(string deviceId, string name, string ip) + => new() + { + DeviceId = deviceId, + Name = name, + IpAddress = ip, + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + string deviceId, + string deviceName, + string signalName, + string reference, + string value) + => new() + { + DeviceId = deviceId, + DeviceName = deviceName, + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Status = "Live", + Value = value + }; + + private static string ExtractMethod(string source, string signature) + { + var start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"Could not find method '{signature}'."); + var openBrace = source.IndexOf('{', start); + Assert.True(openBrace >= 0, $"Could not find opening brace for '{signature}'."); + + var depth = 0; + for (var index = openBrace; index < source.Length; index++) + { + if (source[index] == '{') depth++; + else if (source[index] == '}' && --depth == 0) return source[start..(index + 1)]; + } + + throw new InvalidDataException($"Method '{signature}' has no balanced closing brace."); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From c5e3ec0ca7cec3c6d40c15840d8de1ce28b5f0a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 09:07:30 +0700 Subject: [PATCH 043/158] FAT P4A: bind evidence by IEDName and IEC telegram --- Services/IoTesting/NativeFatArmCoordinator.cs | 37 +++- .../NativeFatCanonicalEvidenceOverlay.cs | 71 ++++++-- .../NativeFatCanonicalEvidenceOverlayTests.cs | 37 +++- ...NativeFatP4AStableEvidenceIdentityTests.cs | 170 ++++++++++++++++++ 4 files changed, 289 insertions(+), 26 deletions(-) create mode 100644 tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs diff --git a/Services/IoTesting/NativeFatArmCoordinator.cs b/Services/IoTesting/NativeFatArmCoordinator.cs index 3da1a59b6..c22df915e 100644 --- a/Services/IoTesting/NativeFatArmCoordinator.cs +++ b/Services/IoTesting/NativeFatArmCoordinator.cs @@ -93,8 +93,26 @@ public NativeFatArmResult Arm( var armed = new ArmedDevice(device.DeviceId, cache, DateTimeOffset.Now); var seeded = 0; + // P4A: only one-to-one IEDName + IEC Telegram identities may own evidence. + // Missing or duplicate identities are skipped instead of being guessed by order, + // SignalName, SelectedIndex or runtime DeviceId. + var identityCounts = device.Points + .Select(point => NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var key) + ? key + : string.Empty) + .Where(key => key.Length > 0) + .GroupBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + foreach (var point in device.Points) { + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var rowKey) || + !identityCounts.TryGetValue(rowKey, out var identityCount) || + identityCount != 1) + { + continue; + } + PropertyChangedEventHandler handler = (_, args) => { if (args.PropertyName is nameof(Iec61850MonitorPoint.Value) or nameof(Iec61850MonitorPoint.DisplayValue)) @@ -107,6 +125,18 @@ public NativeFatArmResult Arm( seeded++; } + if (armed.Subscriptions.Count == 0) + { + stopwatch.Stop(); + return new NativeFatArmResult( + false, + false, + 0, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} has no uniquely addressable IEDName + IEC Telegram FAT rows; evidence was not armed."); + } + _armedDevices[device.DeviceId] = armed; cache.IsArmed = true; cache.ArmedAt = armed.ArmedAt; @@ -143,11 +173,12 @@ private bool ObserveCanonicalValue(ArmedDevice armed, Iec61850MonitorPoint point if (!IsEvidenceCandidate(point, value)) return false; + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var rowKey)) + return false; + lock (armed.Gate) { - if (!armed.Cache.EvidenceByRow.TryGetValue( - NativeFatCanonicalEvidenceOverlay.BuildRowKey(point), - out var slot)) + if (!armed.Cache.EvidenceByRow.TryGetValue(rowKey, out var slot)) { NativeFatCanonicalEvidenceOverlay.Write( armed.Cache, diff --git a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs index d52386b84..d2d9657bf 100644 --- a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs +++ b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs @@ -10,27 +10,39 @@ public enum NativeFatEvidenceField } /// -/// Sparse FAT-only evidence keyed by the canonical Engineering live-row identity. -/// It deliberately never owns or clones IEC 61850 rows: the row object remains -/// Iec61850MonitorPoint and this service stores only operator evidence. +/// Sparse FAT-only evidence keyed by the stable IEC 61850 identity of the canonical +/// Engineering row: IEDName + IEC Telegram. DeviceId, row index, selected index and +/// display labels are deliberately excluded so evidence cannot jump to another signal +/// after reordering or recreation of the Engineering runtime device. /// public static class NativeFatCanonicalEvidenceOverlay { public static string BuildRowKey(Iec61850MonitorPoint point) { ArgumentNullException.ThrowIfNull(point); + // P4A compatibility note: point.PointKey is intentionally not used here because + // it contains the runtime DeviceId and is not stable across Engineering recreation. + return TryBuildRowKey(point, out var rowKey) ? rowKey : string.Empty; + } - // Engineering already defines PointKey as DeviceId + normalized IEC reference. - // Reuse that identity verbatim so FAT cannot invent a second semantic key space. - if (!string.IsNullOrWhiteSpace(point.IecReference)) - return point.PointKey; + public static bool TryBuildRowKey(Iec61850MonitorPoint point, out string rowKey) + { + ArgumentNullException.ThrowIfNull(point); + return TryBuildRowKey(point.DeviceName, point.IecTelegram, out rowKey); + } - // Defensive fallback for non-canonical/manual monitor rows. Automatic static - // DataSet FAT is expected to take the PointKey path above. - if (!string.IsNullOrWhiteSpace(point.IecTelegram)) - return $"{point.DeviceId}|{point.IecTelegram.Trim()}"; + internal static bool TryBuildRowKey(string? iedName, string? iecTelegram, out string rowKey) + { + var normalizedIedName = NormalizeIedName(iedName); + var normalizedTelegram = NormalizeTelegram(iecTelegram); + if (normalizedIedName.Length == 0 || normalizedTelegram.Length == 0) + { + rowKey = string.Empty; + return false; + } - return $"{point.DeviceId}|{point.SignalName.Trim()}|{point.IecDataType.Trim()}"; + rowKey = $"{normalizedIedName}|{normalizedTelegram}"; + return true; } public static string Read( @@ -41,9 +53,12 @@ public static string Read( ArgumentNullException.ThrowIfNull(cache); ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return string.Empty; + lock (cache.EvidenceByRow) { - if (!cache.EvidenceByRow.TryGetValue(BuildRowKey(point), out var slot)) + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) return string.Empty; return field switch @@ -65,7 +80,11 @@ public static void Write( ArgumentNullException.ThrowIfNull(cache); ArgumentNullException.ThrowIfNull(point); - var key = BuildRowKey(point); + // Fail closed: a canonical FAT row without both IEDName and IEC Telegram has no + // stable identity. Never fall back to DeviceId, row position or SignalName. + if (!TryBuildRowKey(point, out var key)) + return; + var text = value ?? string.Empty; lock (cache.EvidenceByRow) @@ -98,9 +117,9 @@ public static void Write( } /// - /// P2 merge rule: persisted evidence may fill missing cells but may never overwrite - /// evidence captured after hydration started. This lets Start FAT remain usable while - /// disk hydration is still completing. + /// P2/P4A merge rule: persisted evidence may fill missing cells but may never overwrite + /// evidence captured after hydration started. Hydration has already resolved every key + /// to one unique canonical IEDName + IEC Telegram identity before this method is called. /// public static int MergeMissing( NativeFatIedSessionCacheState cache, @@ -114,6 +133,9 @@ public static int MergeMissing( { foreach (var pair in hydratedEvidence) { + if (string.IsNullOrWhiteSpace(pair.Key)) + continue; + var incoming = pair.Value; if (incoming == null || (string.IsNullOrWhiteSpace(incoming.Value1) && @@ -168,6 +190,21 @@ public static IReadOnlyDictionary Snapshot( } } + internal static string NormalizeIedName(string? iedName) + => (iedName ?? string.Empty).Trim().ToLowerInvariant(); + + internal static string NormalizeTelegram(string? iecTelegram) + { + var text = (iecTelegram ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".", StringComparison.Ordinal) + .ToLowerInvariant(); + while (text.Contains("..", StringComparison.Ordinal)) + text = text.Replace("..", ".", StringComparison.Ordinal); + return text.Trim('.'); + } + private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) => new() { diff --git a/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs b/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs index fb226c4dd..86fdcbed7 100644 --- a/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs +++ b/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs @@ -6,16 +6,41 @@ namespace ARSAS.Tests; public sealed class NativeFatCanonicalEvidenceOverlayTests { [Fact] - public void BuildRowKey_ReusesEngineeringPointKeyInsteadOfDisplayName() + public void BuildRowKey_UsesIedNameAndIecTelegram_NotDeviceIdOrDisplayName() { - var first = Point("dev-1", "Trip", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); - var second = Point("dev-1", "Trip", "AA1E1F06R4LD0/GGIO1.Ind2.stVal"); + var first = Point("runtime-a", "Trip A", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); + var recreated = Point("runtime-b", "Renamed display text", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); + var secondSignal = Point("runtime-a", "Trip A", "AA1E1F06R4LD0/GGIO1.Ind2.stVal"); - Assert.Equal(first.PointKey, NativeFatCanonicalEvidenceOverlay.BuildRowKey(first)); - Assert.Equal(second.PointKey, NativeFatCanonicalEvidenceOverlay.BuildRowKey(second)); + Assert.Equal("aa1e1f06r4|ld0/ggio1.ind1.stval", NativeFatCanonicalEvidenceOverlay.BuildRowKey(first)); + Assert.Equal( + NativeFatCanonicalEvidenceOverlay.BuildRowKey(first), + NativeFatCanonicalEvidenceOverlay.BuildRowKey(recreated)); Assert.NotEqual( NativeFatCanonicalEvidenceOverlay.BuildRowKey(first), - NativeFatCanonicalEvidenceOverlay.BuildRowKey(second)); + NativeFatCanonicalEvidenceOverlay.BuildRowKey(secondSignal)); + Assert.DoesNotContain("runtime-a", NativeFatCanonicalEvidenceOverlay.BuildRowKey(first), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Trip A", NativeFatCanonicalEvidenceOverlay.BuildRowKey(first), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MissingStableIdentity_FailsClosedWithoutSignalNameFallback() + { + var cache = new NativeFatIedSessionCacheState(); + var point = new Iec61850MonitorPoint + { + DeviceId = "runtime-a", + DeviceName = "AA1E1F06R4", + SignalName = "CSWI.Pos", + IecReference = string.Empty, + IecDataType = "BOOLEAN" + }; + + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "SHOULD-NOT-BIND"); + + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.BuildRowKey(point)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Empty(cache.EvidenceByRow); } [Fact] diff --git a/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs b/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs new file mode 100644 index 000000000..b5e1a2fbd --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs @@ -0,0 +1,170 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP4AStableEvidenceIdentityTests +{ + [Fact] + public void RecreatedAndReorderedRows_KeepCswiEvidenceOnExactTelegram_NotThdPpv() + { + var cache = new NativeFatIedSessionCacheState(); + var cswiBefore = Point( + "runtime-before", + "CSWI.Pos", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]"); + NativeFatCanonicalEvidenceOverlay.Write( + cache, + cswiBefore, + NativeFatEvidenceField.Value1, + "Open [01]"); + + // Runtime DeviceId and display labels change, and row order is deliberately reversed. + // Stable IEC identity must still bind only the exact telegram. + var thdAfter = Point( + "runtime-after", + "THD phase voltage renamed", + "AA1E1F06R4LD0/MMXU1.ThdPPV.phsA.cVal.mag.f", + "2.20"); + var cswiAfter = Point( + "runtime-after", + "Breaker position renamed", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Closed [10]"); + var reordered = new[] { thdAfter, cswiAfter }; + + Assert.Equal(thdAfter, reordered[0]); + Assert.Equal(cswiAfter, reordered[1]); + Assert.Equal( + "Open [01]", + NativeFatCanonicalEvidenceOverlay.Read(cache, cswiAfter, NativeFatEvidenceField.Value1)); + Assert.Equal( + string.Empty, + NativeFatCanonicalEvidenceOverlay.Read(cache, thdAfter, NativeFatEvidenceField.Value1)); + } + + [Fact] + public void Arm_DuplicateStableTelegram_FailsClosedWithoutCapturingEitherRow() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-arm-ambiguous"); + device.Points.Add(Point( + device.DeviceId, + "CSWI.Pos A", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]")); + device.Points.Add(Point( + device.DeviceId, + "CSWI.Pos B", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Closed [10]")); + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + + Assert.False(result.Succeeded); + Assert.Equal(0, result.ArmedRows); + Assert.False(cache.IsArmed); + Assert.Empty(cache.EvidenceByRow); + } + + [Fact] + public void Arm_MixedUniqueAndAmbiguousRows_ArmsOnlyUniqueStableIdentities() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-arm-mixed"); + var duplicateA = Point( + device.DeviceId, + "CSWI.Pos A", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]"); + var duplicateB = Point( + device.DeviceId, + "CSWI.Pos B", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Closed [10]"); + var unique = Point( + device.DeviceId, + "Trip", + "AA1E1F06R4LD0/PTRC1.Tr.general", + "False"); + device.Points.Add(duplicateA); + device.Points.Add(unique); + device.Points.Add(duplicateB); + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + + Assert.True(result.Succeeded); + Assert.Equal(1, result.ArmedRows); + Assert.Equal("False", NativeFatCanonicalEvidenceOverlay.Read(cache, unique, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, duplicateA, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, duplicateB, NativeFatEvidenceField.Value1)); + } + + [Fact] + public void P4A_SourceContract_RejectsIndexDisplayAndRuntimeIdentityFallbacks() + { + var overlay = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); + var arm = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatArmCoordinator.cs")); + + Assert.Contains("point.DeviceName", overlay, StringComparison.Ordinal); + Assert.Contains("point.IecTelegram", overlay, StringComparison.Ordinal); + Assert.Contains("runtime DeviceId", overlay, StringComparison.Ordinal); + Assert.Contains("not used here", overlay, StringComparison.Ordinal); + Assert.DoesNotContain("SelectedIndex", overlay, StringComparison.Ordinal); + Assert.Contains("identityCount != 1", arm, StringComparison.Ordinal); + Assert.Contains("SignalName, SelectedIndex or runtime DeviceId", arm, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(string deviceId) + => new() + { + DeviceId = deviceId, + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + string deviceId, + string signalName, + string reference, + string value) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Status = "Live", + SourceMode = "Static DataSet reporting", + Value = value + }; + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From 28a55fb08777d2301f90d1c4d82626a68c2c041e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 09:37:32 +0700 Subject: [PATCH 044/158] FAT P4B: preserve timestamped structured evidence --- Models/NativeFatEvidenceOverlayState.cs | 98 +-- Services/IoTesting/NativeFatArmCoordinator.cs | 594 +++++++-------- .../NativeFatCanonicalEvidenceOverlay.cs | 676 ++++++++++++------ .../NativeFatEvidenceHydrationService.cs | 203 +++++- .../NativeFatPrintPreviewSnapshot.cs | 6 +- .../NativeFatCanonicalEvidenceOverlayTests.cs | 10 +- .../NativeFatP1DArmCoordinatorTests.cs | 16 +- .../NativeFatP2EvidenceHydrationTests.cs | 20 +- ...NativeFatP4AStableEvidenceIdentityTests.cs | 10 +- ...eFatP4BStructuredTimestampEvidenceTests.cs | 252 +++++++ 10 files changed, 1271 insertions(+), 614 deletions(-) create mode 100644 tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs diff --git a/Models/NativeFatEvidenceOverlayState.cs b/Models/NativeFatEvidenceOverlayState.cs index d01a9e78b..408fef61e 100644 --- a/Models/NativeFatEvidenceOverlayState.cs +++ b/Models/NativeFatEvidenceOverlayState.cs @@ -1,46 +1,52 @@ -namespace ArIED61850Tester.Models; - -/// -/// FAT-only evidence payload layered over a canonical Engineering row. -/// -public sealed class NativeFatEvidenceSlotState -{ - public string Value1 { get; set; } = string.Empty; - public string Value2 { get; set; } = string.Empty; - public string Result { get; set; } = string.Empty; -} - -public enum NativeFatEvidenceHydrationState -{ - NotStarted, - Hydrating, - Resolved, - Failed -} - -/// -/// Per-IED UI/evidence state. EvidenceByRow is sparse and keyed by the canonical -/// Engineering point key; it is not a second signal/row collection. -/// -public sealed class NativeFatIedSessionCacheState -{ - public string? ActiveRowKey { get; set; } - public int LastScrollIndex { get; set; } - public bool IsArmed { get; set; } - public DateTimeOffset? ArmedAt { get; set; } - public long LastArmElapsedMilliseconds { get; set; } - - // P2 evidence hydration is deliberately independent from canonical row binding. - // Engineering rows render immediately; only the three sparse evidence columns wait. - public NativeFatEvidenceHydrationState EvidenceHydrationState { get; set; } = - NativeFatEvidenceHydrationState.NotStarted; - public long EvidenceHydrationGeneration { get; set; } - public DateTimeOffset? EvidenceHydratedAt { get; set; } - public string EvidenceHydrationError { get; set; } = string.Empty; - public long LastHydrationElapsedMilliseconds { get; set; } - public bool IsEvidenceHydrating => - EvidenceHydrationState == NativeFatEvidenceHydrationState.Hydrating; - - public Dictionary EvidenceByRow { get; } = - new(StringComparer.OrdinalIgnoreCase); -} +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Models; + +/// +/// FAT-only evidence payload layered over one canonical Engineering row. +/// Value1/Value2 keep their raw text for backwards compatibility while the structured +/// FatValueEvidence objects preserve timestamp, quality, acquisition source and sequence. +/// +public sealed class NativeFatEvidenceSlotState +{ + public string Value1 { get; set; } = string.Empty; + public string Value2 { get; set; } = string.Empty; + public FatValueEvidence? Value1Evidence { get; set; } + public FatValueEvidence? Value2Evidence { get; set; } + public string Result { get; set; } = string.Empty; +} + +public enum NativeFatEvidenceHydrationState +{ + NotStarted, + Hydrating, + Resolved, + Failed +} + +/// +/// Per-IED UI/evidence state. EvidenceByRow is sparse and keyed by stable +/// IEDName + IEC Telegram identity; it is not a second signal/row collection. +/// +public sealed class NativeFatIedSessionCacheState +{ + public string? ActiveRowKey { get; set; } + public int LastScrollIndex { get; set; } + public bool IsArmed { get; set; } + public DateTimeOffset? ArmedAt { get; set; } + public long LastArmElapsedMilliseconds { get; set; } + + // P2 evidence hydration is deliberately independent from canonical row binding. + // Engineering rows render immediately; only the three sparse evidence columns wait. + public NativeFatEvidenceHydrationState EvidenceHydrationState { get; set; } = + NativeFatEvidenceHydrationState.NotStarted; + public long EvidenceHydrationGeneration { get; set; } + public DateTimeOffset? EvidenceHydratedAt { get; set; } + public string EvidenceHydrationError { get; set; } = string.Empty; + public long LastHydrationElapsedMilliseconds { get; set; } + public bool IsEvidenceHydrating => + EvidenceHydrationState == NativeFatEvidenceHydrationState.Hydrating; + + public Dictionary EvidenceByRow { get; } = + new(StringComparer.OrdinalIgnoreCase); +} diff --git a/Services/IoTesting/NativeFatArmCoordinator.cs b/Services/IoTesting/NativeFatArmCoordinator.cs index c22df915e..3655dd31c 100644 --- a/Services/IoTesting/NativeFatArmCoordinator.cs +++ b/Services/IoTesting/NativeFatArmCoordinator.cs @@ -1,288 +1,306 @@ -using System.ComponentModel; -using System.Diagnostics; -using ArIED61850Tester.Models; - -namespace ArIED61850Tester.Services.IoTesting; - -public sealed record NativeFatArmResult( - bool Succeeded, - bool AlreadyArmed, - int ArmedRows, - int SeededValue1Rows, - long ElapsedMilliseconds, - string Message); - -public sealed class NativeFatEvidenceChangedEventArgs : EventArgs -{ - public NativeFatEvidenceChangedEventArgs( - string deviceId, - Iec61850MonitorPoint point, - NativeFatEvidenceField field) - { - DeviceId = deviceId; - Point = point; - Field = field; - } - - public string DeviceId { get; } - public Iec61850MonitorPoint Point { get; } - public NativeFatEvidenceField Field { get; } -} - -/// -/// P1D evidence ARM coordinator for the native Engineering FAT surface. -/// It never connects, discovers, imports SCL, changes reporting, changes polling cadence, -/// or creates a second point collection. It only subscribes to the canonical Engineering -/// Iec61850MonitorPoint instances that are already live and records sparse evidence. -/// -public sealed class NativeFatArmCoordinator : IDisposable -{ - private readonly Dictionary _armedDevices = - new(StringComparer.OrdinalIgnoreCase); - - public event EventHandler? EvidenceChanged; - - public NativeFatArmResult Arm( - Iec61850MonitorDevice device, - NativeFatIedSessionCacheState cache) - { - ArgumentNullException.ThrowIfNull(device); - ArgumentNullException.ThrowIfNull(cache); - - var stopwatch = Stopwatch.StartNew(); - - if (!device.IsConnected || !device.IsMonitoring) - { - stopwatch.Stop(); - return new NativeFatArmResult( - false, - false, - 0, - 0, - stopwatch.ElapsedMilliseconds, - $"{device.Name} must already be connected and monitoring in Engineering before FAT can be armed."); - } - - if (device.Points.Count == 0) - { - stopwatch.Stop(); - return new NativeFatArmResult( - false, - false, - 0, - 0, - stopwatch.ElapsedMilliseconds, - $"{device.Name} has no canonical Engineering live rows to arm."); - } - - if (_armedDevices.TryGetValue(device.DeviceId, out var existing)) - { - stopwatch.Stop(); - cache.IsArmed = true; - cache.ArmedAt ??= existing.ArmedAt; - cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; - return new NativeFatArmResult( - true, - true, - existing.Subscriptions.Count, - 0, - stopwatch.ElapsedMilliseconds, - $"{device.Name} FAT is already armed on the shared Engineering acquisition stream."); - } - - var armed = new ArmedDevice(device.DeviceId, cache, DateTimeOffset.Now); - var seeded = 0; - - // P4A: only one-to-one IEDName + IEC Telegram identities may own evidence. - // Missing or duplicate identities are skipped instead of being guessed by order, - // SignalName, SelectedIndex or runtime DeviceId. - var identityCounts = device.Points - .Select(point => NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var key) - ? key - : string.Empty) - .Where(key => key.Length > 0) - .GroupBy(key => key, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); - - foreach (var point in device.Points) - { - if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var rowKey) || - !identityCounts.TryGetValue(rowKey, out var identityCount) || - identityCount != 1) - { - continue; - } - - PropertyChangedEventHandler handler = (_, args) => - { - if (args.PropertyName is nameof(Iec61850MonitorPoint.Value) or nameof(Iec61850MonitorPoint.DisplayValue)) - ObserveCanonicalValue(armed, point); - }; - - point.PropertyChanged += handler; - armed.Subscriptions.Add(new PointSubscription(point, handler)); - if (ObserveCanonicalValue(armed, point)) - seeded++; - } - - if (armed.Subscriptions.Count == 0) - { - stopwatch.Stop(); - return new NativeFatArmResult( - false, - false, - 0, - 0, - stopwatch.ElapsedMilliseconds, - $"{device.Name} has no uniquely addressable IEDName + IEC Telegram FAT rows; evidence was not armed."); - } - - _armedDevices[device.DeviceId] = armed; - cache.IsArmed = true; - cache.ArmedAt = armed.ArmedAt; - stopwatch.Stop(); - cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; - - return new NativeFatArmResult( - true, - false, - armed.Subscriptions.Count, - seeded, - stopwatch.ElapsedMilliseconds, - $"{device.Name} FAT armed on {armed.Subscriptions.Count} canonical Engineering row(s) in {stopwatch.ElapsedMilliseconds} ms; acquisition was not restarted."); - } - - public bool IsArmed(string? deviceId) - => !string.IsNullOrWhiteSpace(deviceId) && _armedDevices.ContainsKey(deviceId); - - public void Dispose() - { - foreach (var armed in _armedDevices.Values) - { - foreach (var subscription in armed.Subscriptions) - subscription.Point.PropertyChanged -= subscription.Handler; - armed.Cache.IsArmed = false; - } - - _armedDevices.Clear(); - } - - private bool ObserveCanonicalValue(ArmedDevice armed, Iec61850MonitorPoint point) - { - var value = point.DisplayValue?.Trim() ?? string.Empty; - if (!IsEvidenceCandidate(point, value)) - return false; - - if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var rowKey)) - return false; - - lock (armed.Gate) - { - if (!armed.Cache.EvidenceByRow.TryGetValue(rowKey, out var slot)) - { - NativeFatCanonicalEvidenceOverlay.Write( - armed.Cache, - point, - NativeFatEvidenceField.Value1, - value); - RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); - return true; - } - - if (string.IsNullOrWhiteSpace(slot.Value1)) - { - NativeFatCanonicalEvidenceOverlay.Write( - armed.Cache, - point, - NativeFatEvidenceField.Value1, - value); - RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); - return true; - } - - if (string.IsNullOrWhiteSpace(slot.Value2)) - { - if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value1, value)) - return false; - - NativeFatCanonicalEvidenceOverlay.Write( - armed.Cache, - point, - NativeFatEvidenceField.Value2, - value); - RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value2); - return true; - } - - // Once a pair exists, only the newest Value 2 is the duplicate guard. A return - // to the prior Value 1 is itself a real transition and must advance the pair. - if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value2, value)) - return false; - - // Keep the current pair aligned to the latest meaningful transition without - // touching Result, which remains an operator/report assessment field. - NativeFatCanonicalEvidenceOverlay.Write( - armed.Cache, - point, - NativeFatEvidenceField.Value1, - slot.Value2); - RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); - - NativeFatCanonicalEvidenceOverlay.Write( - armed.Cache, - point, - NativeFatEvidenceField.Value2, - value); - RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value2); - return true; - } - } - - private static bool IsEvidenceCandidate(Iec61850MonitorPoint point, string value) - { - if (string.IsNullOrWhiteSpace(value) || value is "-" or "—") - return false; - - if (value.Equals("Pending", StringComparison.OrdinalIgnoreCase) || - value.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var quality = point.Quality ?? string.Empty; - return !quality.Contains("bad", StringComparison.OrdinalIgnoreCase) && - !quality.Contains("invalid", StringComparison.OrdinalIgnoreCase) && - !quality.Contains("questionable", StringComparison.OrdinalIgnoreCase); - } - - private void RaiseEvidenceChanged( - string deviceId, - Iec61850MonitorPoint point, - NativeFatEvidenceField field) - => EvidenceChanged?.Invoke( - this, - new NativeFatEvidenceChangedEventArgs(deviceId, point, field)); - - private sealed class ArmedDevice - { - public ArmedDevice( - string deviceId, - NativeFatIedSessionCacheState cache, - DateTimeOffset armedAt) - { - DeviceId = deviceId; - Cache = cache; - ArmedAt = armedAt; - } - - public string DeviceId { get; } - public NativeFatIedSessionCacheState Cache { get; } - public DateTimeOffset ArmedAt { get; } - public object Gate { get; } = new(); - public List Subscriptions { get; } = new(); - } - - private sealed record PointSubscription( - Iec61850MonitorPoint Point, - PropertyChangedEventHandler Handler); -} +using System.ComponentModel; +using System.Diagnostics; +using System.Threading; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatArmResult( + bool Succeeded, + bool AlreadyArmed, + int ArmedRows, + int SeededValue1Rows, + long ElapsedMilliseconds, + string Message); + +public sealed class NativeFatEvidenceChangedEventArgs : EventArgs +{ + public NativeFatEvidenceChangedEventArgs( + string deviceId, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + DeviceId = deviceId; + Point = point; + Field = field; + } + + public string DeviceId { get; } + public Iec61850MonitorPoint Point { get; } + public NativeFatEvidenceField Field { get; } +} + +/// +/// P1D evidence ARM coordinator for the native Engineering FAT surface. +/// It never connects, discovers, imports SCL, changes reporting, changes polling cadence, +/// or creates a second point collection. It only subscribes to the canonical Engineering +/// Iec61850MonitorPoint instances that are already live and records sparse evidence. +/// +public sealed class NativeFatArmCoordinator : IDisposable +{ + private readonly Dictionary _armedDevices = + new(StringComparer.OrdinalIgnoreCase); + + public event EventHandler? EvidenceChanged; + + public NativeFatArmResult Arm( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(cache); + + var stopwatch = Stopwatch.StartNew(); + + if (!device.IsConnected || !device.IsMonitoring) + { + stopwatch.Stop(); + return new NativeFatArmResult( + false, + false, + 0, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} must already be connected and monitoring in Engineering before FAT can be armed."); + } + + if (device.Points.Count == 0) + { + stopwatch.Stop(); + return new NativeFatArmResult( + false, + false, + 0, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} has no canonical Engineering live rows to arm."); + } + + if (_armedDevices.TryGetValue(device.DeviceId, out var existing)) + { + stopwatch.Stop(); + cache.IsArmed = true; + cache.ArmedAt ??= existing.ArmedAt; + cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + return new NativeFatArmResult( + true, + true, + existing.Subscriptions.Count, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} FAT is already armed on the shared Engineering acquisition stream."); + } + + var armed = new ArmedDevice(device.DeviceId, cache, DateTimeOffset.Now); + var seeded = 0; + + // P4A: only one-to-one IEDName + IEC Telegram identities may own evidence. + // Missing or duplicate identities are skipped instead of being guessed by order, + // SignalName, SelectedIndex or runtime DeviceId. + var identityCounts = device.Points + .Select(point => NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var key) + ? key + : string.Empty) + .Where(key => key.Length > 0) + .GroupBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + + foreach (var point in device.Points) + { + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var rowKey) || + !identityCounts.TryGetValue(rowKey, out var identityCount) || + identityCount != 1) + { + continue; + } + + PropertyChangedEventHandler handler = (_, args) => + { + if (args.PropertyName is not (nameof(Iec61850MonitorPoint.Value) or nameof(Iec61850MonitorPoint.DisplayValue))) + return; + + // The Engineering UI applies Value before timestamp/quality/source/sequence in + // one dispatcher flush. Post the capture to that same synchronization context so + // P4B reads one coherent sample after the remaining metadata has been applied. + var context = SynchronizationContext.Current; + if (context is DispatcherSynchronizationContext) + context.Post(_ => ObserveCanonicalValue(armed, point), null); + else + ObserveCanonicalValue(armed, point); + }; + + point.PropertyChanged += handler; + armed.Subscriptions.Add(new PointSubscription(point, handler)); + if (ObserveCanonicalValue(armed, point)) + seeded++; + } + + if (armed.Subscriptions.Count == 0) + { + stopwatch.Stop(); + return new NativeFatArmResult( + false, + false, + 0, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} has no uniquely addressable IEDName + IEC Telegram FAT rows; evidence was not armed."); + } + + _armedDevices[device.DeviceId] = armed; + cache.IsArmed = true; + cache.ArmedAt = armed.ArmedAt; + stopwatch.Stop(); + cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + return new NativeFatArmResult( + true, + false, + armed.Subscriptions.Count, + seeded, + stopwatch.ElapsedMilliseconds, + $"{device.Name} FAT armed on {armed.Subscriptions.Count} canonical Engineering row(s) in {stopwatch.ElapsedMilliseconds} ms; acquisition was not restarted."); + } + + public bool IsArmed(string? deviceId) + => !string.IsNullOrWhiteSpace(deviceId) && _armedDevices.ContainsKey(deviceId); + + public void Dispose() + { + foreach (var armed in _armedDevices.Values) + { + foreach (var subscription in armed.Subscriptions) + subscription.Point.PropertyChanged -= subscription.Handler; + armed.Cache.IsArmed = false; + } + + _armedDevices.Clear(); + } + + private bool ObserveCanonicalValue(ArmedDevice armed, Iec61850MonitorPoint point) + { + var value = point.DisplayValue?.Trim() ?? string.Empty; + if (!IsEvidenceCandidate(point, value)) + return false; + + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var rowKey)) + return false; + + lock (armed.Gate) + { + if (!armed.Cache.EvidenceByRow.TryGetValue(rowKey, out var slot)) + { + NativeFatCanonicalEvidenceOverlay.WriteCapture( + armed.Cache, + point, + NativeFatEvidenceField.Value1, + value, + FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Now); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); + return true; + } + + if (string.IsNullOrWhiteSpace(slot.Value1)) + { + NativeFatCanonicalEvidenceOverlay.WriteCapture( + armed.Cache, + point, + NativeFatEvidenceField.Value1, + value, + FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Now); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); + return true; + } + + if (string.IsNullOrWhiteSpace(slot.Value2)) + { + if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value1, value)) + return false; + + NativeFatCanonicalEvidenceOverlay.WriteCapture( + armed.Cache, + point, + NativeFatEvidenceField.Value2, + value, + FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Now); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value2); + return true; + } + + // Once a pair exists, only the newest Value 2 is the duplicate guard. A return + // to the prior Value 1 is itself a real transition and must advance the pair. + if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value2, value)) + return false; + + // Keep the current pair aligned to the latest meaningful transition without + // touching Result, which remains an operator/report assessment field. + NativeFatCanonicalEvidenceOverlay.PromoteValue2ToValue1( + armed.Cache, + point); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); + + NativeFatCanonicalEvidenceOverlay.WriteCapture( + armed.Cache, + point, + NativeFatEvidenceField.Value2, + value, + FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Now); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value2); + return true; + } + } + + private static bool IsEvidenceCandidate(Iec61850MonitorPoint point, string value) + { + if (string.IsNullOrWhiteSpace(value) || value is "-" or "—") + return false; + + if (value.Equals("Pending", StringComparison.OrdinalIgnoreCase) || + value.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var quality = point.Quality ?? string.Empty; + return !quality.Contains("bad", StringComparison.OrdinalIgnoreCase) && + !quality.Contains("invalid", StringComparison.OrdinalIgnoreCase) && + !quality.Contains("questionable", StringComparison.OrdinalIgnoreCase); + } + + private void RaiseEvidenceChanged( + string deviceId, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + => EvidenceChanged?.Invoke( + this, + new NativeFatEvidenceChangedEventArgs(deviceId, point, field)); + + private sealed class ArmedDevice + { + public ArmedDevice( + string deviceId, + NativeFatIedSessionCacheState cache, + DateTimeOffset armedAt) + { + DeviceId = deviceId; + Cache = cache; + ArmedAt = armedAt; + } + + public string DeviceId { get; } + public NativeFatIedSessionCacheState Cache { get; } + public DateTimeOffset ArmedAt { get; } + public object Gate { get; } = new(); + public List Subscriptions { get; } = new(); + } + + private sealed record PointSubscription( + Iec61850MonitorPoint Point, + PropertyChangedEventHandler Handler); +} diff --git a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs index d2d9657bf..5b37a6bce 100644 --- a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs +++ b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs @@ -1,230 +1,446 @@ -using ArIED61850Tester.Models; - -namespace ArIED61850Tester.Services.IoTesting; - -public enum NativeFatEvidenceField -{ - Value1, - Value2, - Result -} - -/// -/// Sparse FAT-only evidence keyed by the stable IEC 61850 identity of the canonical -/// Engineering row: IEDName + IEC Telegram. DeviceId, row index, selected index and -/// display labels are deliberately excluded so evidence cannot jump to another signal -/// after reordering or recreation of the Engineering runtime device. -/// -public static class NativeFatCanonicalEvidenceOverlay -{ - public static string BuildRowKey(Iec61850MonitorPoint point) - { - ArgumentNullException.ThrowIfNull(point); - // P4A compatibility note: point.PointKey is intentionally not used here because - // it contains the runtime DeviceId and is not stable across Engineering recreation. - return TryBuildRowKey(point, out var rowKey) ? rowKey : string.Empty; - } - - public static bool TryBuildRowKey(Iec61850MonitorPoint point, out string rowKey) - { - ArgumentNullException.ThrowIfNull(point); - return TryBuildRowKey(point.DeviceName, point.IecTelegram, out rowKey); - } - - internal static bool TryBuildRowKey(string? iedName, string? iecTelegram, out string rowKey) - { - var normalizedIedName = NormalizeIedName(iedName); - var normalizedTelegram = NormalizeTelegram(iecTelegram); - if (normalizedIedName.Length == 0 || normalizedTelegram.Length == 0) - { - rowKey = string.Empty; - return false; - } - - rowKey = $"{normalizedIedName}|{normalizedTelegram}"; - return true; - } - - public static string Read( - NativeFatIedSessionCacheState cache, - Iec61850MonitorPoint point, - NativeFatEvidenceField field) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(point); - - if (!TryBuildRowKey(point, out var key)) - return string.Empty; - - lock (cache.EvidenceByRow) - { - if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) - return string.Empty; - - return field switch - { - NativeFatEvidenceField.Value1 => slot.Value1, - NativeFatEvidenceField.Value2 => slot.Value2, - NativeFatEvidenceField.Result => slot.Result, - _ => string.Empty - }; - } - } - - public static void Write( - NativeFatIedSessionCacheState cache, - Iec61850MonitorPoint point, - NativeFatEvidenceField field, - string? value) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(point); - - // Fail closed: a canonical FAT row without both IEDName and IEC Telegram has no - // stable identity. Never fall back to DeviceId, row position or SignalName. - if (!TryBuildRowKey(point, out var key)) - return; - - var text = value ?? string.Empty; - - lock (cache.EvidenceByRow) - { - if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) - { - // Reading/clearing an untouched cell must not allocate evidence. - if (string.IsNullOrWhiteSpace(text)) - return; - - slot = new NativeFatEvidenceSlotState(); - cache.EvidenceByRow[key] = slot; - } - - switch (field) - { - case NativeFatEvidenceField.Value1: - slot.Value1 = text; - break; - case NativeFatEvidenceField.Value2: - slot.Value2 = text; - break; - case NativeFatEvidenceField.Result: - slot.Result = text; - break; - } - - RemoveIfEmpty(cache, key, slot); - } - } - - /// - /// P2/P4A merge rule: persisted evidence may fill missing cells but may never overwrite - /// evidence captured after hydration started. Hydration has already resolved every key - /// to one unique canonical IEDName + IEC Telegram identity before this method is called. - /// - public static int MergeMissing( - NativeFatIedSessionCacheState cache, - IReadOnlyDictionary hydratedEvidence) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(hydratedEvidence); - - var mergedRows = 0; - lock (cache.EvidenceByRow) - { - foreach (var pair in hydratedEvidence) - { - if (string.IsNullOrWhiteSpace(pair.Key)) - continue; - - var incoming = pair.Value; - if (incoming == null || - (string.IsNullOrWhiteSpace(incoming.Value1) && - string.IsNullOrWhiteSpace(incoming.Value2) && - string.IsNullOrWhiteSpace(incoming.Result))) - { - continue; - } - - if (!cache.EvidenceByRow.TryGetValue(pair.Key, out var current)) - { - cache.EvidenceByRow[pair.Key] = Clone(incoming); - mergedRows++; - continue; - } - - var changed = false; - if (string.IsNullOrWhiteSpace(current.Value1) && !string.IsNullOrWhiteSpace(incoming.Value1)) - { - current.Value1 = incoming.Value1; - changed = true; - } - if (string.IsNullOrWhiteSpace(current.Value2) && !string.IsNullOrWhiteSpace(incoming.Value2)) - { - current.Value2 = incoming.Value2; - changed = true; - } - if (string.IsNullOrWhiteSpace(current.Result) && !string.IsNullOrWhiteSpace(incoming.Result)) - { - current.Result = incoming.Result; - changed = true; - } - - if (changed) - mergedRows++; - } - } - - return mergedRows; - } - - public static IReadOnlyDictionary Snapshot( - NativeFatIedSessionCacheState cache) - { - ArgumentNullException.ThrowIfNull(cache); - lock (cache.EvidenceByRow) - { - return cache.EvidenceByRow.ToDictionary( - pair => pair.Key, - pair => Clone(pair.Value), - StringComparer.OrdinalIgnoreCase); - } - } - - internal static string NormalizeIedName(string? iedName) - => (iedName ?? string.Empty).Trim().ToLowerInvariant(); - - internal static string NormalizeTelegram(string? iecTelegram) - { - var text = (iecTelegram ?? string.Empty) - .Trim() - .Replace('$', '.') - .Replace("..", ".", StringComparison.Ordinal) - .ToLowerInvariant(); - while (text.Contains("..", StringComparison.Ordinal)) - text = text.Replace("..", ".", StringComparison.Ordinal); - return text.Trim('.'); - } - - private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) - => new() - { - Value1 = source.Value1, - Value2 = source.Value2, - Result = source.Result - }; - - private static void RemoveIfEmpty( - NativeFatIedSessionCacheState cache, - string key, - NativeFatEvidenceSlotState slot) - { - // Keep the overlay genuinely sparse. Clearing the last evidence value removes - // the entry rather than leaving a shadow row behind. - if (string.IsNullOrWhiteSpace(slot.Value1) && - string.IsNullOrWhiteSpace(slot.Value2) && - string.IsNullOrWhiteSpace(slot.Result)) - { - cache.EvidenceByRow.Remove(key); - } - } -} +using System.Globalization; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public enum NativeFatEvidenceField +{ + Value1, + Value2, + Result +} + +/// +/// Sparse FAT-only evidence keyed by the stable IEC 61850 identity of the canonical +/// Engineering row: IEDName + IEC Telegram. DeviceId, row index, selected index and +/// display labels are deliberately excluded so evidence cannot jump to another signal +/// after reordering or recreation of the Engineering runtime device. +/// +public static class NativeFatCanonicalEvidenceOverlay +{ + public static string BuildRowKey(Iec61850MonitorPoint point) + { + ArgumentNullException.ThrowIfNull(point); + // P4A compatibility note: point.PointKey is intentionally not used here because + // it contains the runtime DeviceId and is not stable across Engineering recreation. + return TryBuildRowKey(point, out var rowKey) ? rowKey : string.Empty; + } + + public static bool TryBuildRowKey(Iec61850MonitorPoint point, out string rowKey) + { + ArgumentNullException.ThrowIfNull(point); + return TryBuildRowKey(point.DeviceName, point.IecTelegram, out rowKey); + } + + internal static bool TryBuildRowKey(string? iedName, string? iecTelegram, out string rowKey) + { + var normalizedIedName = NormalizeIedName(iedName); + var normalizedTelegram = NormalizeTelegram(iecTelegram); + if (normalizedIedName.Length == 0 || normalizedTelegram.Length == 0) + { + rowKey = string.Empty; + return false; + } + + rowKey = $"{normalizedIedName}|{normalizedTelegram}"; + return true; + } + + /// + /// Default/operator-facing read used by the native FAT grid. Value 1 / Value 2 include + /// the evidence timestamp while Result remains plain text. + /// + public static string Read( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + => ReadDisplay(cache, point, field); + + /// + /// Raw evidence value used for semantic comparison, persistence tests and report adapters + /// that carry timestamp metadata separately. + /// + public static string ReadRaw( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + + if (!TryBuildRowKey(point, out var key)) + return string.Empty; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => RawValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value2 => RawValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Result => slot.Result, + _ => string.Empty + }; + } + } + + /// + /// Operator-facing evidence text. P4B deliberately keeps timestamp out of the raw value + /// so comparisons remain type-safe while the grid/report can show "value - timestamp". + /// IED time is preferred; ARSAS capture time is the explicit fallback. + /// + public static string ReadDisplay( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + + if (!TryBuildRowKey(point, out var key)) + return string.Empty; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => DisplayValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value2 => DisplayValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Result => slot.Result, + _ => string.Empty + }; + } + } + + public static FatValueEvidence? ReadCapture( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + + if (!TryBuildRowKey(point, out var key)) + return null; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return null; + + return field switch + { + NativeFatEvidenceField.Value1 => slot.Value1Evidence, + NativeFatEvidenceField.Value2 => slot.Value2Evidence, + _ => null + }; + } + } + + /// + /// Compatibility/operator write. Value slots still become structured evidence, using + /// the current point metadata and ARSAS time when there is no separate acquisition event. + /// + public static void Write( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field, + string? value) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + + if (!TryBuildRowKey(point, out var key)) + return; + + var supplied = value ?? string.Empty; + if ((field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) && + string.Equals( + ReadDisplay(cache, point, field).Trim(), + supplied.Trim(), + StringComparison.Ordinal)) + { + // WPF editing starts from the rendered "value - timestamp" text. Committing an + // untouched cell must preserve the original evidence metadata, not recapture it. + return; + } + + var text = (field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) + ? StripDisplayTimestamp(supplied) + : supplied; + if ((field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) && + !string.IsNullOrWhiteSpace(text)) + { + WriteCapture( + cache, + point, + field, + text, + FatEvidenceCaptureKind.OperatorRecapture, + DateTimeOffset.Now); + return; + } + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + slot = new NativeFatEvidenceSlotState(); + cache.EvidenceByRow[key] = slot; + } + + switch (field) + { + case NativeFatEvidenceField.Value1: + slot.Value1 = text; + slot.Value1Evidence = null; + break; + case NativeFatEvidenceField.Value2: + slot.Value2 = text; + slot.Value2Evidence = null; + break; + case NativeFatEvidenceField.Result: + slot.Result = text; + break; + } + + RemoveIfEmpty(cache, key, slot); + } + } + + public static void WriteCapture( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field, + string rawValue, + FatEvidenceCaptureKind captureKind, + DateTimeOffset capturedAt) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (field is not (NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2)) + throw new ArgumentOutOfRangeException(nameof(field), field, "Only Value 1 / Value 2 are structured captures."); + if (!TryBuildRowKey(point, out var key) || string.IsNullOrWhiteSpace(rawValue)) + return; + + var slotKind = field == NativeFatEvidenceField.Value1 ? FatValueSlot.Value1 : FatValueSlot.Value2; + var evidence = new FatValueEvidence( + Guid.NewGuid(), + slotKind, + captureKind, + rawValue.Trim(), + capturedAt, + IoTestValueNormalizer.ParseIedTimestamp(point.DeviceTimestamp), + string.IsNullOrWhiteSpace(point.Quality) ? "Unknown" : point.Quality.Trim(), + string.IsNullOrWhiteSpace(point.SourceMode) ? "Engineering live" : point.SourceMode.Trim(), + point.Sequence, + -1); + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + { + slot = new NativeFatEvidenceSlotState(); + cache.EvidenceByRow[key] = slot; + } + + if (field == NativeFatEvidenceField.Value1) + { + slot.Value1 = evidence.RawValue; + slot.Value1Evidence = evidence; + } + else + { + slot.Value2 = evidence.RawValue; + slot.Value2Evidence = evidence; + } + } + } + + /// + /// Rolls the latest Value 2 observation into Value 1 without losing its original + /// relay/ARSAS timestamp or source metadata. + /// + public static bool PromoteValue2ToValue1( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return false; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return false; + + var raw = RawValue(slot.Value2Evidence, slot.Value2); + if (string.IsNullOrWhiteSpace(raw)) + return false; + + slot.Value1 = raw; + slot.Value1Evidence = slot.Value2Evidence is null + ? null + : slot.Value2Evidence with { Slot = FatValueSlot.Value1 }; + return true; + } + } + + /// + /// Persisted evidence may fill missing cells but may never overwrite evidence captured + /// after hydration started. Structured metadata moves with its raw value atomically. + /// + public static int MergeMissing( + NativeFatIedSessionCacheState cache, + IReadOnlyDictionary hydratedEvidence) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(hydratedEvidence); + + var mergedRows = 0; + lock (cache.EvidenceByRow) + { + foreach (var pair in hydratedEvidence) + { + if (string.IsNullOrWhiteSpace(pair.Key)) + continue; + + var incoming = pair.Value; + if (incoming == null || IsEmpty(incoming)) + continue; + + if (!cache.EvidenceByRow.TryGetValue(pair.Key, out var current)) + { + cache.EvidenceByRow[pair.Key] = Clone(incoming); + mergedRows++; + continue; + } + + var changed = false; + if (!HasValue1(current) && HasValue1(incoming)) + { + current.Value1 = RawValue(incoming.Value1Evidence, incoming.Value1); + current.Value1Evidence = incoming.Value1Evidence; + changed = true; + } + if (!HasValue2(current) && HasValue2(incoming)) + { + current.Value2 = RawValue(incoming.Value2Evidence, incoming.Value2); + current.Value2Evidence = incoming.Value2Evidence; + changed = true; + } + if (string.IsNullOrWhiteSpace(current.Result) && !string.IsNullOrWhiteSpace(incoming.Result)) + { + current.Result = incoming.Result; + changed = true; + } + + if (changed) + mergedRows++; + } + } + + return mergedRows; + } + + public static IReadOnlyDictionary Snapshot( + NativeFatIedSessionCacheState cache) + { + ArgumentNullException.ThrowIfNull(cache); + lock (cache.EvidenceByRow) + { + return cache.EvidenceByRow.ToDictionary( + pair => pair.Key, + pair => Clone(pair.Value), + StringComparer.OrdinalIgnoreCase); + } + } + + internal static string NormalizeIedName(string? iedName) + => (iedName ?? string.Empty).Trim().ToLowerInvariant(); + + internal static string NormalizeTelegram(string? iecTelegram) + { + var text = (iecTelegram ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".", StringComparison.Ordinal) + .ToLowerInvariant(); + while (text.Contains("..", StringComparison.Ordinal)) + text = text.Replace("..", ".", StringComparison.Ordinal); + return text.Trim('.'); + } + + private static string StripDisplayTimestamp(string value) + { + var text = value?.Trim() ?? string.Empty; + var separator = text.LastIndexOf(" - ", StringComparison.Ordinal); + if (separator <= 0) + return text; + + var suffix = text[(separator + 3)..]; + return DateTime.TryParseExact( + suffix, + "yyyy-MM-dd HH:mm:ss.fff", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _) + ? text[..separator].Trim() + : text; + } + + private static string DisplayValue(FatValueEvidence? evidence, string legacyRaw) + { + var raw = RawValue(evidence, legacyRaw); + if (string.IsNullOrWhiteSpace(raw)) + return string.Empty; + if (evidence is null) + return raw; + + var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; + return $"{raw} - {timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)}"; + } + + private static string RawValue(FatValueEvidence? evidence, string legacyRaw) + => !string.IsNullOrWhiteSpace(evidence?.RawValue) + ? evidence.RawValue.Trim() + : legacyRaw?.Trim() ?? string.Empty; + + private static bool HasValue1(NativeFatEvidenceSlotState slot) + => !string.IsNullOrWhiteSpace(RawValue(slot.Value1Evidence, slot.Value1)); + + private static bool HasValue2(NativeFatEvidenceSlotState slot) + => !string.IsNullOrWhiteSpace(RawValue(slot.Value2Evidence, slot.Value2)); + + private static bool IsEmpty(NativeFatEvidenceSlotState slot) + => !HasValue1(slot) && !HasValue2(slot) && string.IsNullOrWhiteSpace(slot.Result); + + private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) + => new() + { + Value1 = RawValue(source.Value1Evidence, source.Value1), + Value2 = RawValue(source.Value2Evidence, source.Value2), + Value1Evidence = source.Value1Evidence, + Value2Evidence = source.Value2Evidence, + Result = source.Result + }; + + private static void RemoveIfEmpty( + NativeFatIedSessionCacheState cache, + string key, + NativeFatEvidenceSlotState slot) + { + if (IsEmpty(slot)) + cache.EvidenceByRow.Remove(key); + } +} diff --git a/Services/IoTesting/NativeFatEvidenceHydrationService.cs b/Services/IoTesting/NativeFatEvidenceHydrationService.cs index c92827473..24fabb8aa 100644 --- a/Services/IoTesting/NativeFatEvidenceHydrationService.cs +++ b/Services/IoTesting/NativeFatEvidenceHydrationService.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; namespace ArIED61850Tester.Services.IoTesting; @@ -24,7 +25,8 @@ public sealed record NativeFatEvidenceHydrationResult( /// public sealed class NativeFatEvidenceHydrationService : IDisposable { - internal const string SnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-1.0"; + internal const string SnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-2.0"; + internal const string LegacySnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-1.0"; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -75,13 +77,19 @@ public async Task HydrateAsync( var identity = CaptureIdentity(device); var canonical = CaptureCanonicalIdentity(device); var stopwatch = Stopwatch.StartNew(); - var path = SnapshotPath(identity.DeviceId); + var path = SnapshotPath(identity.DeviceName); + var legacyNativePath = LegacyDeviceSnapshotPath(identity.DeviceId); await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { cancellationToken.ThrowIfCancellationRequested(); - if (!File.Exists(path)) + var sourcePath = File.Exists(path) + ? path + : File.Exists(legacyNativePath) + ? legacyNativePath + : FindLegacyNativeSnapshotByIedName(identity.DeviceName); + if (sourcePath == null) { // First native launch may still have evidence in the pre-P1 persistent // project snapshot. Read it passively and map only uniquely covered rows. @@ -107,21 +115,34 @@ public async Task HydrateAsync( $"No saved FAT evidence exists yet for {identity.DeviceName}."); } - var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); + var bytes = await File.ReadAllBytesAsync(sourcePath, cancellationToken).ConfigureAwait(false); var document = JsonSerializer.Deserialize(bytes, JsonOptions) ?? throw new InvalidDataException("Native FAT evidence snapshot is invalid."); - if (!string.Equals(document.Schema, SnapshotSchema, StringComparison.Ordinal)) + if (!string.Equals(document.Schema, SnapshotSchema, StringComparison.Ordinal) && + !string.Equals(document.Schema, LegacySnapshotSchema, StringComparison.Ordinal)) + { throw new InvalidDataException($"Unsupported native FAT evidence schema '{document.Schema}'."); - if (!string.Equals(document.DeviceId, identity.DeviceId, StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException("Native FAT evidence belongs to a different Engineering device identity."); + } + + // P4A/P4B identity is domain-stable. Runtime DeviceId is diagnostics only and + // may legitimately change after Engineering recreation or application restart. + if (!string.IsNullOrWhiteSpace(document.DeviceName) && + !NativeFatCanonicalEvidenceOverlay.NormalizeIedName(document.DeviceName) + .Equals( + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(identity.DeviceName), + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("Native FAT evidence belongs to a different IEDName."); + } var loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); var ignored = 0; foreach (var pair in document.EvidenceByRow ?? new Dictionary()) { cancellationToken.ThrowIfCancellationRequested(); - if (!canonical.RowKeys.Contains(pair.Key)) + var resolvedKey = ResolvePersistedRowKey(pair.Key, identity, canonical, document.DeviceName); + if (resolvedKey == null) { ignored++; continue; @@ -131,7 +152,7 @@ public async Task HydrateAsync( if (source == null || IsEmpty(source)) continue; - loaded[pair.Key] = Clone(source); + loaded[resolvedKey] = Clone(source); } stopwatch.Stop(); @@ -153,7 +174,7 @@ public async Task HydrateAsync( stopwatch.Stop(); return new NativeFatEvidenceHydrationResult( false, - File.Exists(path), + File.Exists(path) || File.Exists(legacyNativePath), 0, 0, stopwatch.ElapsedMilliseconds, @@ -197,7 +218,7 @@ public async Task SaveAsync( EvidenceByRow = evidence }; var bytes = JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions); - var path = SnapshotPath(identity.DeviceId); + var path = SnapshotPath(identity.DeviceName); await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -222,13 +243,49 @@ public async Task SaveAsync( } } - internal string SnapshotPath(string deviceId) + internal string SnapshotPath(string iedName) + { + var stableIdentity = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(stableIdentity)); + var token = Convert.ToHexString(digest).ToLowerInvariant()[..24]; + return Path.Combine(_rootDirectory, $"{token}.native-fat-evidence.json"); + } + + private string LegacyDeviceSnapshotPath(string deviceId) { var digest = SHA256.HashData(Encoding.UTF8.GetBytes(deviceId.Trim().ToLowerInvariant())); var token = Convert.ToHexString(digest).ToLowerInvariant()[..24]; return Path.Combine(_rootDirectory, $"{token}.native-fat-evidence.json"); } + private string? FindLegacyNativeSnapshotByIedName(string iedName) + { + if (!Directory.Exists(_rootDirectory)) + return null; + + var normalized = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + foreach (var candidate in Directory + .EnumerateFiles(_rootDirectory, "*.native-fat-evidence.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(File.GetLastWriteTimeUtc)) + { + try + { + using var stream = File.OpenRead(candidate); + using var document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty("deviceName", out var name)) + continue; + if (NativeFatCanonicalEvidenceOverlay.NormalizeIedName(name.GetString()) == normalized) + return candidate; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + continue; + } + } + + return null; + } + public void Dispose() { if (_disposed) @@ -363,10 +420,14 @@ private static LegacyHydration ExtractLegacyEvidence( if (!point.TryGetProperty("runtime", out var runtime) || runtime.ValueKind != JsonValueKind.Object) continue; - var value1 = GetEvidenceRaw(runtime, "value1Evidence"); + var value1Capture = GetEvidenceCapture(runtime, "value1Evidence", FatValueSlot.Value1) + ?? GetEvidenceCapture(runtime, "onEvidence", FatValueSlot.Value1); + var value2Capture = GetEvidenceCapture(runtime, "value2Evidence", FatValueSlot.Value2) + ?? GetEvidenceCapture(runtime, "offEvidence", FatValueSlot.Value2); + var value1 = value1Capture?.RawValue ?? GetEvidenceRaw(runtime, "value1Evidence"); if (string.IsNullOrWhiteSpace(value1)) value1 = GetEvidenceRaw(runtime, "onEvidence"); - var value2 = GetEvidenceRaw(runtime, "value2Evidence"); + var value2 = value2Capture?.RawValue ?? GetEvidenceRaw(runtime, "value2Evidence"); if (string.IsNullOrWhiteSpace(value2)) value2 = GetEvidenceRaw(runtime, "offEvidence"); var result = ReadLegacyResult(point, runtime, value1, value2); @@ -392,9 +453,15 @@ private static LegacyHydration ExtractLegacyEvidence( } if (string.IsNullOrWhiteSpace(slot.Value1) && !string.IsNullOrWhiteSpace(value1)) + { slot.Value1 = value1; + slot.Value1Evidence = value1Capture; + } if (string.IsNullOrWhiteSpace(slot.Value2) && !string.IsNullOrWhiteSpace(value2)) + { slot.Value2 = value2; + slot.Value2Evidence = value2Capture; + } if (string.IsNullOrWhiteSpace(slot.Result) && !string.IsNullOrWhiteSpace(result)) slot.Result = result; } @@ -402,6 +469,50 @@ private static LegacyHydration ExtractLegacyEvidence( return new LegacyHydration(loaded, ignored); } + private static string? ResolvePersistedRowKey( + string persistedKey, + NativeFatDeviceIdentity identity, + CanonicalEvidenceIdentity canonical, + string persistedIedName) + { + if (string.IsNullOrWhiteSpace(persistedKey)) + return null; + if (canonical.RowKeys.Contains(persistedKey)) + return persistedKey; + + // Pre-P4A snapshots used runtime DeviceId|reference. Recover only through the + // existing unique IEC-reference alias map; ambiguity remains fail-closed. + var separator = persistedKey.IndexOf('|'); + if (separator <= 0 || separator >= persistedKey.Length - 1) + return null; + + var owner = persistedKey[..separator].Trim(); + var reference = persistedKey[(separator + 1)..].Trim(); + var ownerMatchesRuntime = owner.Equals(identity.DeviceId, StringComparison.OrdinalIgnoreCase); + var ownerMatchesIed = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(owner) + .Equals( + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(identity.DeviceName), + StringComparison.OrdinalIgnoreCase); + var documentMatchesIed = !string.IsNullOrWhiteSpace(persistedIedName) && + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(persistedIedName) + .Equals( + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(identity.DeviceName), + StringComparison.OrdinalIgnoreCase); + if (!ownerMatchesRuntime && !ownerMatchesIed && !documentMatchesIed) + return null; + + var matches = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var alias in ReferenceAliases(reference)) + { + if (canonical.AliasToRowKey.TryGetValue(alias, out var rowKey) && + !string.IsNullOrWhiteSpace(rowKey)) + { + matches.Add(rowKey); + } + } + return matches.Count == 1 ? matches.First() : null; + } + private static string? ResolveLegacyRowKey( JsonElement point, CanonicalEvidenceIdentity canonical) @@ -489,6 +600,57 @@ private static int ReadCaptureModeOrdinal(JsonElement point) return 0; } + private static FatValueEvidence? GetEvidenceCapture( + JsonElement runtime, + string property, + FatValueSlot slot) + { + if (!runtime.TryGetProperty(property, out var evidence) || evidence.ValueKind != JsonValueKind.Object) + return null; + + var raw = GetString(evidence, "rawValue").Trim(); + if (raw.Length == 0) + return null; + + var capturedAt = GetDateTimeOffset(evidence, "capturedAt"); + var iedTimestamp = GetDateTimeOffset(evidence, "iedTimestamp"); + if (capturedAt == null && iedTimestamp == null) + return null; + + return new FatValueEvidence( + Guid.NewGuid(), + slot, + FatEvidenceCaptureKind.AutomaticValue, + raw, + capturedAt ?? iedTimestamp!.Value, + iedTimestamp, + GetString(evidence, "quality"), + GetString(evidence, "acquisitionSource"), + GetInt64(evidence, "sequence"), + GetInt64(evidence, "connectionGeneration", -1)); + } + + private static DateTimeOffset? GetDateTimeOffset(JsonElement element, string property) + { + var text = GetString(element, property); + return DateTimeOffset.TryParse( + text, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AllowWhiteSpaces | System.Globalization.DateTimeStyles.RoundtripKind, + out var parsed) + ? parsed + : null; + } + + private static long GetInt64(JsonElement element, string property, long fallback = 0) + { + if (!element.TryGetProperty(property, out var value)) + return fallback; + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number)) + return number; + return long.TryParse(value.ToString(), out number) ? number : fallback; + } + private static string GetEvidenceRaw(JsonElement runtime, string property) { if (!runtime.TryGetProperty(property, out var evidence) || @@ -518,7 +680,8 @@ private static CanonicalEvidenceIdentity CaptureCanonicalIdentity(Iec61850Monito var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var point in device.Points) { - var key = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var key)) + continue; keys.Add(key); AddAliases(aliases, point.IecReference, key); AddAliases(aliases, point.IecTelegram, key); @@ -592,14 +755,16 @@ private static NativeFatDeviceIdentity CaptureIdentity(Iec61850MonitorDevice dev private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) => new() { - Value1 = source.Value1, - Value2 = source.Value2, + Value1 = source.Value1Evidence?.RawValue ?? source.Value1, + Value2 = source.Value2Evidence?.RawValue ?? source.Value2, + Value1Evidence = source.Value1Evidence, + Value2Evidence = source.Value2Evidence, Result = source.Result }; private static bool IsEmpty(NativeFatEvidenceSlotState slot) - => string.IsNullOrWhiteSpace(slot.Value1) && - string.IsNullOrWhiteSpace(slot.Value2) && + => string.IsNullOrWhiteSpace(slot.Value1Evidence?.RawValue ?? slot.Value1) && + string.IsNullOrWhiteSpace(slot.Value2Evidence?.RawValue ?? slot.Value2) && string.IsNullOrWhiteSpace(slot.Result); private static NativeFatEvidenceHydrationResult EmptyResult(long elapsedMilliseconds, string message) diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index 8b9ba253d..6d5ccc3f9 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -59,15 +59,15 @@ public static NativeFatPrintPreviewSnapshot Capture( // is copied now; the preview never binds back to device.Points or EvidenceByRow. var rows = device.Points.Select(point => { - var value1 = NativeFatCanonicalEvidenceOverlay.Read( + var value1 = NativeFatCanonicalEvidenceOverlay.ReadRaw( cache, point, NativeFatEvidenceField.Value1).Trim(); - var value2 = NativeFatCanonicalEvidenceOverlay.Read( + var value2 = NativeFatCanonicalEvidenceOverlay.ReadRaw( cache, point, NativeFatEvidenceField.Value2).Trim(); - var result = NativeFatCanonicalEvidenceOverlay.Read( + var result = NativeFatCanonicalEvidenceOverlay.ReadRaw( cache, point, NativeFatEvidenceField.Result).Trim(); diff --git a/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs b/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs index 86fdcbed7..d6e03e25b 100644 --- a/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs +++ b/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs @@ -39,7 +39,7 @@ public void MissingStableIdentity_FailsClosedWithoutSignalNameFallback() NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "SHOULD-NOT-BIND"); Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.BuildRowKey(point)); - Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); Assert.Empty(cache.EvidenceByRow); } @@ -50,7 +50,7 @@ public void ReadUntouchedEvidence_DoesNotAllocateShadowRow() var point = Point("dev-1", "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); Assert.Equal(string.Empty, - NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); Assert.Empty(cache.EvidenceByRow); } @@ -65,9 +65,9 @@ public void WriteEvidence_UsesOneSparseSlotAndClearingLastValueRemovesIt() NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "PASS"); Assert.Single(cache.EvidenceByRow); - Assert.Equal("OPEN", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); - Assert.Equal("CLOSE", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); - Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result)); + Assert.Equal("OPEN", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("CLOSE", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Result)); NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, ""); NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, ""); diff --git a/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs b/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs index 7d4d98456..0f642c403 100644 --- a/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs +++ b/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs @@ -37,16 +37,16 @@ public void Arm_SeedsValue1AndCapturesValue2WithoutReplacingCanonicalRows() Assert.Equal(1, result.SeededValue1Rows); Assert.Single(device.Points); Assert.Same(canonicalReference, device.Points[0]); - Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); - Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); point.Value = "Closed [10]"; Assert.Single(device.Points); Assert.Same(canonicalReference, device.Points[0]); - Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); - Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); - Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result)); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Result)); } [Fact] @@ -63,9 +63,9 @@ public void Arm_LatestMeaningfulTransitionRollsValuePairAndLeavesResultOperatorO point.Value = "True"; point.Value = "False"; - Assert.Equal("True", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); - Assert.Equal("False", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); - Assert.Equal("REVIEW", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result)); + Assert.Equal("True", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("False", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal("REVIEW", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Result)); } [Fact] diff --git a/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs b/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs index 2e193d9a0..0b2189a2f 100644 --- a/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs +++ b/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs @@ -33,10 +33,10 @@ public async Task PersistThenHydrate_RestoresOnlySparseEvidenceForCanonicalRows( Assert.True(result.SnapshotFound); Assert.Equal(1, result.LoadedRows); Assert.Equal(1, merged); - Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.Read(restored, first, NativeFatEvidenceField.Value1)); - Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.Read(restored, first, NativeFatEvidenceField.Value2)); - Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.Read(restored, first, NativeFatEvidenceField.Result)); - Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(restored, second, NativeFatEvidenceField.Value1)); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, first, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, first, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, first, NativeFatEvidenceField.Result)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, second, NativeFatEvidenceField.Value1)); Assert.Equal(2, device.Points.Count); } finally @@ -68,9 +68,9 @@ public async Task HydrationMerge_NeverOverwritesEvidenceCapturedAfterHydrationSt NativeFatCanonicalEvidenceOverlay.MergeMissing(live, result.EvidenceByRow); - Assert.Equal("NEW-LIVE-V1", NativeFatCanonicalEvidenceOverlay.Read(live, point, NativeFatEvidenceField.Value1)); - Assert.Equal("OLD-V2", NativeFatCanonicalEvidenceOverlay.Read(live, point, NativeFatEvidenceField.Value2)); - Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.Read(live, point, NativeFatEvidenceField.Result)); + Assert.Equal("NEW-LIVE-V1", NativeFatCanonicalEvidenceOverlay.ReadRaw(live, point, NativeFatEvidenceField.Value1)); + Assert.Equal("OLD-V2", NativeFatCanonicalEvidenceOverlay.ReadRaw(live, point, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.ReadRaw(live, point, NativeFatEvidenceField.Result)); } finally { @@ -138,9 +138,9 @@ await File.WriteAllTextAsync( Assert.True(result.Succeeded); Assert.True(result.SnapshotFound); Assert.Equal(1, result.LoadedRows); - Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.Read(restored, point, NativeFatEvidenceField.Value1)); - Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.Read(restored, point, NativeFatEvidenceField.Value2)); - Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.Read(restored, point, NativeFatEvidenceField.Result)); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, point, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, point, NativeFatEvidenceField.Result)); Assert.Single(device.Points); } finally diff --git a/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs b/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs index b5e1a2fbd..189eb902c 100644 --- a/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs @@ -38,10 +38,10 @@ public void RecreatedAndReorderedRows_KeepCswiEvidenceOnExactTelegram_NotThdPpv( Assert.Equal(cswiAfter, reordered[1]); Assert.Equal( "Open [01]", - NativeFatCanonicalEvidenceOverlay.Read(cache, cswiAfter, NativeFatEvidenceField.Value1)); + NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, cswiAfter, NativeFatEvidenceField.Value1)); Assert.Equal( string.Empty, - NativeFatCanonicalEvidenceOverlay.Read(cache, thdAfter, NativeFatEvidenceField.Value1)); + NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, thdAfter, NativeFatEvidenceField.Value1)); } [Fact] @@ -98,9 +98,9 @@ public void Arm_MixedUniqueAndAmbiguousRows_ArmsOnlyUniqueStableIdentities() Assert.True(result.Succeeded); Assert.Equal(1, result.ArmedRows); - Assert.Equal("False", NativeFatCanonicalEvidenceOverlay.Read(cache, unique, NativeFatEvidenceField.Value1)); - Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, duplicateA, NativeFatEvidenceField.Value1)); - Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.Read(cache, duplicateB, NativeFatEvidenceField.Value1)); + Assert.Equal("False", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, unique, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, duplicateA, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, duplicateB, NativeFatEvidenceField.Value1)); } [Fact] diff --git a/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs b/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs new file mode 100644 index 000000000..a4ff00767 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs @@ -0,0 +1,252 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP4BStructuredTimestampEvidenceTests +{ + [Fact] + public void Arm_CapturesValueWithRelayTimestampQualitySourceAndSequence() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-a"); + var point = Point( + device.DeviceId, + "CSWI.Pos", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "True", + "2026-09-12T06:46:31.958+07:00", + sequence: 42); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + var evidence = NativeFatCanonicalEvidenceOverlay.ReadCapture( + cache, + point, + NativeFatEvidenceField.Value1); + + Assert.True(result.Succeeded); + Assert.NotNull(evidence); + Assert.Equal("True", evidence!.RawValue); + Assert.Equal("Good", evidence.Quality); + Assert.Equal("Static DataSet reporting", evidence.AcquisitionSource); + Assert.Equal(42, evidence.Sequence); + Assert.Equal(31, evidence.IedTimestamp!.Value.Second); + Assert.Equal(958, evidence.IedTimestamp.Value.Millisecond); + Assert.Equal( + "True - 2026-09-12 06:46:31.958", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal( + "True", + NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + } + + [Fact] + public void CaptureWithoutRelayTimestamp_UsesArsasCaptureTimeAsDisplayFallback() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-fallback"); + var point = Point( + device.DeviceId, + "Analog current", + "AA1E1F06R4MEAS/MMXU1.A.phsA.cVal.mag.f", + "1247.32", + "-", + sequence: 7); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + var before = DateTimeOffset.Now.AddSeconds(-1); + + Assert.True(coordinator.Arm(device, cache).Succeeded); + var after = DateTimeOffset.Now.AddSeconds(1); + var evidence = NativeFatCanonicalEvidenceOverlay.ReadCapture( + cache, + point, + NativeFatEvidenceField.Value1); + + Assert.NotNull(evidence); + Assert.Null(evidence!.IedTimestamp); + Assert.InRange(evidence.CapturedAt, before, after); + Assert.Equal( + $"1247.32 - {evidence.CapturedAt:yyyy-MM-dd HH:mm:ss.fff}", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + } + + [Fact] + public void RollingPair_PreservesOriginalTimestampWhenValue2BecomesValue1() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-roll"); + var point = Point( + device.DeviceId, + "Breaker position", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]", + "2026-09-12T06:46:30.100+07:00", + sequence: 1); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + Assert.True(coordinator.Arm(device, cache).Succeeded); + + point.DeviceTimestamp = "2026-09-12T06:46:31.200+07:00"; + point.Sequence = 2; + point.Value = "Closed [10]"; + + point.DeviceTimestamp = "2026-09-12T06:46:32.300+07:00"; + point.Sequence = 3; + point.Value = "Open [01]"; + + var value1 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); + var value2 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value2); + + Assert.NotNull(value1); + Assert.NotNull(value2); + Assert.Equal("Closed [10]", value1!.RawValue); + Assert.Equal(200, value1.IedTimestamp!.Value.Millisecond); + Assert.Equal("Open [01]", value2!.RawValue); + Assert.Equal(300, value2.IedTimestamp!.Value.Millisecond); + Assert.Equal( + "Closed [10] - 2026-09-12 06:46:31.200", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal( + "Open [01] - 2026-09-12 06:46:32.300", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); + } + + [Fact] + public async Task PersistHydrate_UsesStableIedNameAndPreservesStructuredEvidenceAcrossRuntimeDeviceId() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + using var coordinator = new NativeFatArmCoordinator(); + var before = Device("runtime-before"); + var beforePoint = Point( + before.DeviceId, + "CSWI.Pos", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "True", + "2026-09-12T06:46:31.958+07:00", + sequence: 77); + before.Points.Add(beforePoint); + var saved = new NativeFatIedSessionCacheState(); + Assert.True(coordinator.Arm(before, saved).Succeeded); + await service.SaveAsync(before, saved); + + var recreated = Device("runtime-after"); + var recreatedPoint = Point( + recreated.DeviceId, + "renamed display", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "False", + "2026-09-12T07:00:00.000+07:00", + sequence: 1); + recreated.Points.Add(recreatedPoint); + + var hydration = await service.HydrateAsync(recreated); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); + var capture = NativeFatCanonicalEvidenceOverlay.ReadCapture( + restored, + recreatedPoint, + NativeFatEvidenceField.Value1); + + Assert.True(hydration.Succeeded); + Assert.True(hydration.SnapshotFound); + Assert.Equal(1, hydration.LoadedRows); + Assert.NotNull(capture); + Assert.Equal("True", capture!.RawValue); + Assert.Equal(77, capture.Sequence); + Assert.Equal(958, capture.IedTimestamp!.Value.Millisecond); + Assert.Equal( + "True - 2026-09-12 06:46:31.958", + NativeFatCanonicalEvidenceOverlay.Read(restored, recreatedPoint, NativeFatEvidenceField.Value1)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void UntouchedRenderedEvidenceCommit_DoesNotRecaptureOrChangeTimestamp() + { + var point = Point( + "runtime-edit", + "Trip", + "AA1E1F06R4LD0/PTRC1.Tr.general", + "False", + "2026-09-12T08:01:02.345+07:00", + sequence: 9); + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value1, + "False", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + new DateTimeOffset(2026, 9, 12, 8, 1, 3, TimeSpan.FromHours(7))); + var before = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); + var rendered = NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1); + + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, rendered); + var after = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); + + Assert.Same(before, after); + Assert.Equal("False - 2026-09-12 08:01:02.345", rendered); + } + + private static Iec61850MonitorDevice Device(string deviceId) + => new() + { + DeviceId = deviceId, + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + string deviceId, + string signalName, + string reference, + string value, + string deviceTimestamp, + long sequence) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + DeviceTimestamp = deviceTimestamp, + Status = "Live", + SourceMode = "Static DataSet reporting", + Sequence = sequence, + Value = value + }; + + private static string TempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "arsas-native-fat-p4b-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + } + } +} From 5ef1b9fa753ffb1ad16d8c852df696e3af53e070 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 09:47:21 +0700 Subject: [PATCH 045/158] FAT P4C: add canonical seven-column contract --- MainWindow.NativeFatP4CColumnContract.cs | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 MainWindow.NativeFatP4CColumnContract.cs diff --git a/MainWindow.NativeFatP4CColumnContract.cs b/MainWindow.NativeFatP4CColumnContract.cs new file mode 100644 index 000000000..8ce68be79 --- /dev/null +++ b/MainWindow.NativeFatP4CColumnContract.cs @@ -0,0 +1,34 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + /// + /// P4C makes FAT a thin view over the canonical IEC Explorer rows. + /// The grid keeps SelectedDevice.Points as its ItemsSource and exposes only the + /// exact Explorer-facing contract plus the three FAT evidence fields. + /// + private void ApplyNativeFatP4CColumnContract() + { + if (_nativeFatCanonicalGrid == null) + return; + + _nativeFatCanonicalGrid.Columns.Clear(); + _nativeFatCanonicalGrid.FrozenColumnCount = 2; + + AddCanonicalTextColumn("Signal", nameof(Iec61850MonitorPoint.SignalName), 220); + AddCanonicalTextColumn("IEC Telegram", nameof(Iec61850MonitorPoint.IecTelegram), 340); + AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 105); + AddCanonicalTemplateColumn("Live Value", "ProcessValueBadgeTemplate", 140); + + // Value 1 / Value 2 deliberately remain row-bound evidence columns. + // Their displayed timestamp is formatted by the P4B structured evidence layer. + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceColumn(this, "Value 1", NativeFatEvidenceField.Value1, 225)); + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceColumn(this, "Value 2", NativeFatEvidenceField.Value2, 225)); + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceColumn(this, "Result", NativeFatEvidenceField.Result, 110)); + } +} From 1a1ae47e262811f5a9ffa93aafb7c7abe650d0be Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 09:47:40 +0700 Subject: [PATCH 046/158] FAT P4C: apply seven-column Explorer view --- MainWindow.ProductionFatTab.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index 21bd6fb1b..1ba9663e7 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -85,7 +85,10 @@ private FrameworkElement BuildProductionFatPermanentHost( var effectiveStatus = isBusy && !string.IsNullOrWhiteSpace(statusText) ? $"{statusText}" : statusText; - return BuildNativeFatCanonicalWorkspace(effectiveStatus); + + var surface = BuildNativeFatCanonicalWorkspace(effectiveStatus); + ApplyNativeFatP4CColumnContract(); + return surface; } internal void ShowProductionFatBootstrapState(string message, bool isBusy) From 45159161212abab3ae28b1b6170be9a7adbbe4e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 09:47:52 +0700 Subject: [PATCH 047/158] FAT P4C: lock canonical seven-column regression --- ...ativeFatP4CCanonicalColumnContractTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs diff --git a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs new file mode 100644 index 000000000..8046372a8 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs @@ -0,0 +1,74 @@ +namespace ARSAS.Tests; + +public sealed class NativeFatP4CCanonicalColumnContractTests +{ + [Fact] + public void P4C_FatExposesExactlyExplorerColumnsPlusEvidence() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var tabSource = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + + var signal = source.IndexOf("AddCanonicalTextColumn(\"Signal\"", StringComparison.Ordinal); + var telegram = source.IndexOf("AddCanonicalTextColumn(\"IEC Telegram\"", StringComparison.Ordinal); + var quality = source.IndexOf("AddCanonicalTextColumn(\"Quality\"", StringComparison.Ordinal); + var liveValue = source.IndexOf("AddCanonicalTemplateColumn(\"Live Value\"", StringComparison.Ordinal); + var value1 = source.IndexOf("\"Value 1\", NativeFatEvidenceField.Value1", StringComparison.Ordinal); + var value2 = source.IndexOf("\"Value 2\", NativeFatEvidenceField.Value2", StringComparison.Ordinal); + var result = source.IndexOf("\"Result\", NativeFatEvidenceField.Result", StringComparison.Ordinal); + + Assert.True(signal >= 0); + Assert.True(telegram > signal); + Assert.True(quality > telegram); + Assert.True(liveValue > quality); + Assert.True(value1 > liveValue); + Assert.True(value2 > value1); + Assert.True(result > value2); + + Assert.Contains("_nativeFatCanonicalGrid.Columns.Clear();", source, StringComparison.Ordinal); + Assert.Contains("ApplyNativeFatP4CColumnContract();", tabSource, StringComparison.Ordinal); + Assert.Contains("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", gridSource, StringComparison.Ordinal); + + Assert.DoesNotContain("\"Status\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Type\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Address\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Message\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Data Reference\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Timestamp\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("ObservableCollection", source, StringComparison.Ordinal); + Assert.DoesNotContain("new Iec61850MonitorPoint", source, StringComparison.Ordinal); + } + + [Fact] + public void P4C_ColumnBindingsUseCanonicalExplorerRowProperties() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + + Assert.Contains("nameof(Iec61850MonitorPoint.SignalName)", source, StringComparison.Ordinal); + Assert.Contains("nameof(Iec61850MonitorPoint.IecTelegram)", source, StringComparison.Ordinal); + Assert.Contains("nameof(Iec61850MonitorPoint.Quality)", source, StringComparison.Ordinal); + Assert.Contains("\"ProcessValueBadgeTemplate\"", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceColumn", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From 8d6045c1d665ceb6ca46835640c234311c3e2e2a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 09:50:12 +0700 Subject: [PATCH 048/158] FAT P4C: fix evidence field namespace --- MainWindow.NativeFatP4CColumnContract.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MainWindow.NativeFatP4CColumnContract.cs b/MainWindow.NativeFatP4CColumnContract.cs index 8ce68be79..9253fd736 100644 --- a/MainWindow.NativeFatP4CColumnContract.cs +++ b/MainWindow.NativeFatP4CColumnContract.cs @@ -1,4 +1,5 @@ using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; namespace ArIED61850Tester; From df7353ed03b14ed30cc7f9814ddd8dc16c0d0ffe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:21:36 +0700 Subject: [PATCH 049/158] FAT: remove duplicate P4C column application --- MainWindow.ProductionFatTab.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index 1ba9663e7..ad8a7d25e 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -86,9 +86,7 @@ private FrameworkElement BuildProductionFatPermanentHost( ? $"{statusText}" : statusText; - var surface = BuildNativeFatCanonicalWorkspace(effectiveStatus); - ApplyNativeFatP4CColumnContract(); - return surface; + return BuildNativeFatCanonicalWorkspace(effectiveStatus); } internal void ShowProductionFatBootstrapState(string message, bool isBusy) From 6dba347732c8b6a75f8cd63186096810679b4961 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:22:34 +0700 Subject: [PATCH 050/158] FAT: make P4C columns canonical grid authority --- MainWindow.NativeFatCanonicalGrid.cs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/MainWindow.NativeFatCanonicalGrid.cs b/MainWindow.NativeFatCanonicalGrid.cs index e2f7ad558..79171a996 100644 --- a/MainWindow.NativeFatCanonicalGrid.cs +++ b/MainWindow.NativeFatCanonicalGrid.cs @@ -38,6 +38,7 @@ public partial class MainWindow /// P1D makes Start FAT an ARM-only operation over those already-live row objects. /// P2 hydrates only sparse evidence asynchronously; canonical rows and live Value never wait. /// P3 builds no report until Print Preview is clicked, then renders an immutable selected-IED snapshot. + /// P4C makes the canonical workspace itself own the exact seven-column FAT thin-view contract. /// private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = null) { @@ -159,17 +160,9 @@ private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = n _nativeFatCanonicalGrid.CellEditEnding += NativeFatCanonicalGrid_CellEditEnding; _nativeFatCanonicalGrid.BeginningEdit += NativeFatCanonicalGrid_BeginningEdit; - AddCanonicalTextColumn("Status", nameof(Iec61850MonitorPoint.Status), 90); - AddCanonicalTextColumn("Type", nameof(Iec61850MonitorPoint.IecDataType), 84); - AddCanonicalTextColumn("Address", nameof(Iec61850MonitorPoint.IecTelegram), 210); - AddCanonicalTextColumn("Message", nameof(Iec61850MonitorPoint.SignalName), 180); - AddCanonicalTextColumn("Data Reference", nameof(Iec61850MonitorPoint.IecReference), 290); - AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 105); - AddCanonicalTextColumn("Timestamp", nameof(Iec61850MonitorPoint.DeviceTimestamp), 155); - AddCanonicalTemplateColumn("Value", "ProcessValueBadgeTemplate", 125); - _nativeFatCanonicalGrid.Columns.Add(new NativeFatEvidenceColumn(this, "Value 1", NativeFatEvidenceField.Value1, 104)); - _nativeFatCanonicalGrid.Columns.Add(new NativeFatEvidenceColumn(this, "Value 2", NativeFatEvidenceField.Value2, 104)); - _nativeFatCanonicalGrid.Columns.Add(new NativeFatEvidenceColumn(this, "Result", NativeFatEvidenceField.Result, 104)); + // P4C single authority: the canonical workspace owns the exact seven-column FAT view. + // ProductionFatTab must not clear/rebuild columns after this point. + ApplyNativeFatP4CColumnContract(); Grid.SetRow(_nativeFatCanonicalGrid, 2); root.Children.Add(_nativeFatCanonicalGrid); From 625b5b9562dd99db2a8fbe6cf2272b0fa965a873 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:22:52 +0700 Subject: [PATCH 051/158] test: lock P4C single-authority column contract --- ...ativeFatP4CCanonicalColumnContractTests.cs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs index 8046372a8..1049ca268 100644 --- a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs @@ -6,8 +6,8 @@ public sealed class NativeFatP4CCanonicalColumnContractTests public void P4C_FatExposesExactlyExplorerColumnsPlusEvidence() { var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); - var tabSource = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var tabSource = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); var signal = source.IndexOf("AddCanonicalTextColumn(\"Signal\"", StringComparison.Ordinal); var telegram = source.IndexOf("AddCanonicalTextColumn(\"IEC Telegram\"", StringComparison.Ordinal); @@ -26,7 +26,8 @@ public void P4C_FatExposesExactlyExplorerColumnsPlusEvidence() Assert.True(result > value2); Assert.Contains("_nativeFatCanonicalGrid.Columns.Clear();", source, StringComparison.Ordinal); - Assert.Contains("ApplyNativeFatP4CColumnContract();", tabSource, StringComparison.Ordinal); + Assert.Contains("ApplyNativeFatP4CColumnContract();", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("ApplyNativeFatP4CColumnContract();", tabSource, StringComparison.Ordinal); Assert.Contains("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", gridSource, StringComparison.Ordinal); Assert.DoesNotContain("\"Status\"", source, StringComparison.Ordinal); @@ -39,6 +40,22 @@ public void P4C_FatExposesExactlyExplorerColumnsPlusEvidence() Assert.DoesNotContain("new Iec61850MonitorPoint", source, StringComparison.Ordinal); } + [Fact] + public void P4C_CanonicalGridBuilderHasNoLegacyColumnInstallationPath() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + + Assert.Contains("ApplyNativeFatP4CColumnContract();", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Status\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Type\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Address\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Message\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Data Reference\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Timestamp\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTemplateColumn(\"Value\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("new NativeFatEvidenceColumn(this, \"Value 1\"", source, StringComparison.Ordinal); + } + [Fact] public void P4C_ColumnBindingsUseCanonicalExplorerRowProperties() { From f3a8b13348928bb47d67400685442d672e57fe34 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:23:49 +0700 Subject: [PATCH 052/158] FAT P4D: align immutable preview snapshot with P4C columns --- .../NativeFatPrintPreviewSnapshot.cs | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index 6d5ccc3f9..ab13e9893 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -5,17 +5,16 @@ namespace ArIED61850Tester.Services.IoTesting; public sealed record NativeFatPrintPreviewRow( string Signal, - string IecReference, - string Type, + string IecTelegram, + string Quality, string LiveValue, string Value1, string Value2, - string Status, string Result); /// -/// P3 immutable, selected-IED-only report input for native Engineering FAT. -/// Capture copies primitive display values from the canonical Engineering rows and +/// Immutable selected-IED-only report input for native Engineering FAT. +/// Capture copies the exact P4C visible contract from canonical Engineering rows plus /// sparse evidence overlay. No live row/evidence object is retained after Capture returns. /// public sealed class NativeFatPrintPreviewSnapshot @@ -44,8 +43,7 @@ private NativeFatPrintPreviewSnapshot( public string IpAddress { get; } public int Port { get; } public IReadOnlyList Rows => _rows; - public int CompleteCount => _rows.Count(row => - row.Status.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase)); + public int CompleteCount => _rows.Count(row => HasEvidence(row.Value1) && HasEvidence(row.Value2)); public string ProgressText => $"{CompleteCount}/{_rows.Count} complete"; public static NativeFatPrintPreviewSnapshot Capture( @@ -56,7 +54,7 @@ public static NativeFatPrintPreviewSnapshot Capture( ArgumentNullException.ThrowIfNull(cache); // Materialize in the current canonical Engineering row order. Every value below - // is copied now; the preview never binds back to device.Points or EvidenceByRow. + // is copied now; P4D never binds back to device.Points or EvidenceByRow. var rows = device.Points.Select(point => { var value1 = NativeFatCanonicalEvidenceOverlay.ReadRaw( @@ -72,20 +70,13 @@ public static NativeFatPrintPreviewSnapshot Capture( point, NativeFatEvidenceField.Result).Trim(); - var status = !string.IsNullOrWhiteSpace(value1) && !string.IsNullOrWhiteSpace(value2) - ? "COMPLETE" - : !string.IsNullOrWhiteSpace(value1) - ? "WAITING V2" - : "WAITING V1"; - return new NativeFatPrintPreviewRow( Copy(point.SignalName), - Copy(point.IecReference), - Copy(point.IecDataType), + Copy(point.IecTelegram), + Copy(point.Quality), Display(point.DisplayValue), Display(value1), Display(value2), - status, Display(result)); }).ToArray(); @@ -98,6 +89,9 @@ public static NativeFatPrintPreviewSnapshot Capture( rows); } + private static bool HasEvidence(string? value) + => !string.IsNullOrWhiteSpace(value) && value.Trim() != "—"; + private static string Copy(string? value) => value?.Trim() ?? string.Empty; From dde92be824b47108aa19d39d66cd8bf55a2a0f99 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:24:21 +0700 Subject: [PATCH 053/158] FAT P4D: add immutable snapshot report layout adapter --- .../IoTesting/NativeFatP4DReportAdapter.cs | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 Services/IoTesting/NativeFatP4DReportAdapter.cs diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs new file mode 100644 index 000000000..3ee1c7805 --- /dev/null +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -0,0 +1,261 @@ +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// P4D bridge from the immutable native FAT snapshot to the shared report command model. +/// This class owns no acquisition/runtime state and performs no SCL import or reconnect. +/// Rendering remains delegated to IoFatReportPreviewDocumentBuilder.Render so WPF preview +/// and the native report stack keep one FixedDocument authority. +/// +internal static class NativeFatP4DReportAdapter +{ + private const double PageWidth = 842d; + private const double PageHeight = 595d; + private const double Margin = 30d; + private const double ContentTop = 466d; + private const double ContentBottom = 52d; + private const double HeaderHeight = 24d; + private const double MinimumRowHeight = 30d; + private const int TelegramCharsPerLine = 46; + + // Exact P4C visible contract. Total width = 782 pt (842 - 2 * 30 margin). + private static readonly double[] Widths = [110d, 245d, 80d, 85d, 85d, 85d, 92d]; + private static readonly string[] Headers = + ["Signal", "IEC Telegram", "Quality", "Live Value", "Value 1", "Value 2", "Result"]; + + private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); + private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); + private static readonly IoFatReportColor SoftBlue = IoFatReportColor.FromHex("EFF6FF"); + private static readonly IoFatReportColor Border = IoFatReportColor.FromHex("DCE5F0"); + private static readonly IoFatReportColor Ink = IoFatReportColor.FromHex("243146"); + private static readonly IoFatReportColor Muted = IoFatReportColor.FromHex("64748B"); + private static readonly IoFatReportColor White = IoFatReportColor.FromHex("FFFFFF"); + private static readonly IoFatReportColor Pass = IoFatReportColor.FromHex("15803D"); + private static readonly IoFatReportColor Attention = IoFatReportColor.FromHex("B45309"); + private static readonly IoFatReportColor Fail = IoFatReportColor.FromHex("B91C1C"); + + public static IoFatReportLayoutPlan Build(NativeFatPrintPreviewSnapshot snapshot, bool draft = true) + { + ArgumentNullException.ThrowIfNull(snapshot); + + var pages = new List>(); + var page = NewPage(pages, snapshot, continued: false); + var y = ContentTop; + DrawTableHeader(page, ref y); + + foreach (var row in snapshot.Rows) + { + var height = GetRowHeight(row); + if (y - height < ContentBottom) + { + page = NewPage(pages, snapshot, continued: true); + y = ContentTop; + DrawTableHeader(page, ref y); + } + + DrawRow(page, row, height, ref y); + } + + if (snapshot.Rows.Count == 0) + { + page.Add(new IoFatReportTextCommand( + Margin, + y - 20d, + 600d, + "No canonical FAT row is present in this snapshot.", + IoFatReportFontKind.Bold, + 8.5d, + Attention)); + } + + for (var index = 0; index < pages.Count; index++) + { + pages[index].Add(new IoFatReportLineCommand(Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d)); + pages[index].Add(new IoFatReportTextCommand( + Margin, + 24d, + 520d, + $"Immutable Engineering FAT snapshot · {snapshot.CapturedAt:yyyy-MM-dd HH:mm:ss zzz}", + IoFatReportFontKind.Regular, + 6.2d, + Muted)); + pages[index].Add(new IoFatReportTextCommand( + PageWidth - Margin - 100d, + 24d, + 100d, + $"Page {index + 1} / {pages.Count}", + IoFatReportFontKind.Regular, + 6.2d, + Muted)); + } + + return new IoFatReportLayoutPlan( + snapshot.DeviceId, + snapshot.CapturedAt, + draft, + pages.Select((commands, index) => + new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, commands.ToArray())).ToArray()); + } + + private static List NewPage( + List> pages, + NativeFatPrintPreviewSnapshot snapshot, + bool continued) + { + var page = new List(); + pages.Add(page); + + page.Add(new IoFatReportTextCommand( + Margin, + 562d, + 520d, + "IEC 61850 FAT Evidence Report", + IoFatReportFontKind.Bold, + 16.8d, + Navy)); + page.Add(new IoFatReportTextCommand( + Margin, + 542d, + 560d, + "Canonical Explorer snapshot · sparse FAT evidence · no acquisition restart", + IoFatReportFontKind.Regular, + 7.6d, + Muted)); + page.Add(new IoFatReportRectCommand( + Margin, + 520d, + PageWidth - (Margin * 2d), + 38d, + 3d, + SoftBlue, + Border, + 0.6d)); + page.Add(new IoFatReportTextCommand( + Margin + 12d, + 499d, + 470d, + continued + ? $"{Clean(snapshot.IedName)} (continued) · {Clean(snapshot.IpAddress)}:{snapshot.Port}" + : $"{Clean(snapshot.IedName)} · {Clean(snapshot.IpAddress)}:{snapshot.Port}", + IoFatReportFontKind.Bold, + 8.6d, + Ink)); + page.Add(new IoFatReportTextCommand( + PageWidth - Margin - 220d, + 499d, + 208d, + snapshot.ProgressText, + IoFatReportFontKind.Bold, + 8.2d, + snapshot.CompleteCount == snapshot.Rows.Count && snapshot.Rows.Count > 0 ? Pass : Blue)); + page.Add(new IoFatReportLineCommand(Margin, 482d, PageWidth - Margin, 482d, Border, 0.7d)); + return page; + } + + private static void DrawTableHeader(List page, ref double y) + { + var x = Margin; + for (var index = 0; index < Headers.Length; index++) + { + page.Add(new IoFatReportRectCommand(x, y, Widths[index], HeaderHeight, 0d, SoftBlue, Border, 0.45d)); + page.Add(new IoFatReportTextCommand( + x + 5d, + y - 15.5d, + Widths[index] - 10d, + Headers[index], + IoFatReportFontKind.Bold, + 6.2d, + Blue)); + x += Widths[index]; + } + y -= HeaderHeight; + } + + private static double GetRowHeight(NativeFatPrintPreviewRow row) + => Math.Max(MinimumRowHeight, 12d + (WrapTelegram(row.IecTelegram).Count * 8.6d)); + + private static void DrawRow( + List page, + NativeFatPrintPreviewRow row, + double height, + ref double y) + { + var cells = new[] + { + Clean(row.Signal), + string.Empty, + Clean(row.Quality), + Clean(row.LiveValue), + Clean(row.Value1), + Clean(row.Value2), + Clean(row.Result) + }; + + var x = Margin; + for (var index = 0; index < cells.Length; index++) + { + page.Add(new IoFatReportRectCommand(x, y, Widths[index], height, 0d, White, Border, 0.35d)); + + if (index == 1) + { + var lineY = y - 12d; + foreach (var line in WrapTelegram(row.IecTelegram)) + { + page.Add(new IoFatReportTextCommand( + x + 5d, + lineY, + Widths[index] - 10d, + line, + IoFatReportFontKind.Mono, + 5.6d, + Ink)); + lineY -= 8.6d; + } + } + else + { + page.Add(new IoFatReportTextCommand( + x + 5d, + y - 18d, + Widths[index] - 10d, + cells[index], + index is 0 or 6 ? IoFatReportFontKind.Bold : IoFatReportFontKind.Regular, + 6.2d, + index == 6 ? ResultColor(row.Result) : Ink)); + } + + x += Widths[index]; + } + + y -= height; + } + + private static IReadOnlyList WrapTelegram(string? value) + { + var text = Clean(value); + if (text.Length == 0) + return ["—"]; + if (text.Length <= TelegramCharsPerLine) + return [text]; + + var lines = new List(); + for (var offset = 0; offset < text.Length; offset += TelegramCharsPerLine) + lines.Add(text.Substring(offset, Math.Min(TelegramCharsPerLine, text.Length - offset))); + return lines; + } + + private static IoFatReportColor ResultColor(string? result) + { + var value = Clean(result); + if (value.Contains("PASS", StringComparison.OrdinalIgnoreCase) || + value.Contains("COMPLETE", StringComparison.OrdinalIgnoreCase)) + return Pass; + if (value.Contains("FAIL", StringComparison.OrdinalIgnoreCase)) + return Fail; + if (value.Contains("REVIEW", StringComparison.OrdinalIgnoreCase)) + return Attention; + return Muted; + } + + private static string Clean(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); +} From d4eb066ff81fe016c7e22698d57c6cf3536e289e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:24:35 +0700 Subject: [PATCH 054/158] FAT P4D: render immutable preview through FixedDocument viewer --- MainWindow.NativeFatPrintPreview.cs | 165 ++++------------------------ 1 file changed, 19 insertions(+), 146 deletions(-) diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs index eacc53c52..d589e201c 100644 --- a/MainWindow.NativeFatPrintPreview.cs +++ b/MainWindow.NativeFatPrintPreview.cs @@ -1,6 +1,5 @@ using System.Windows; using System.Windows.Controls; -using System.Windows.Data; using System.Windows.Media; using ArIED61850Tester.Services.IoTesting; @@ -9,7 +8,6 @@ namespace ArIED61850Tester; public partial class MainWindow { private const string NativeFatPrintPreviewTitle = "IEC 61850 FAT Evidence Report"; - private const string NativeFatPrintPreviewSubtitle = "Static DataSet verification · generic Value 1 / Value 2 evidence · source identity preserved"; private Button? _nativeFatPrintPreviewButton; private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) @@ -36,165 +34,40 @@ private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) } /// - /// P3 renderer. This method receives only an immutable selected-IED snapshot and never - /// binds to SelectedDevice, canonical live rows, sparse evidence, or acquisition state. - /// The window therefore remains frozen even while Engineering continues monitoring. + /// P4D preview path: immutable selected-IED snapshot -> thin report layout adapter -> + /// existing FixedDocument renderer -> DocumentViewer. No live row, evidence cache, + /// SCL import, discovery, reconnect, or second acquisition engine is retained here. /// private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) { ArgumentNullException.ThrowIfNull(snapshot); + var layout = NativeFatP4DReportAdapter.Build(snapshot, draft: true); + var document = IoFatReportPreviewDocumentBuilder.Render(layout); + var preview = new Window { Owner = this, Title = NativeFatPrintPreviewTitle, - Width = 1180, - Height = 820, - MinWidth = 900, - MinHeight = 620, + Width = 1220, + Height = 860, + MinWidth = 920, + MinHeight = 640, WindowStartupLocation = WindowStartupLocation.CenterOwner, - Background = new SolidColorBrush(Color.FromRgb(241, 245, 249)) + Background = new SolidColorBrush(Color.FromRgb(232, 237, 244)) }; - var root = new Grid { Margin = new Thickness(18) }; - root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - - var page = new Border + var viewer = new DocumentViewer { - Background = Brushes.White, - BorderBrush = new SolidColorBrush(Color.FromRgb(218, 226, 238)), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(30, 26, 30, 24), - Effect = null + Document = document, + Margin = new Thickness(12), + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch }; - var pageGrid = new Grid(); - pageGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - pageGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(18) }); - pageGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - pageGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(18) }); - pageGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - - var heading = new StackPanel(); - heading.Children.Add(new TextBlock - { - Text = NativeFatPrintPreviewTitle, - FontSize = 24, - FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(23, 32, 51)) - }); - heading.Children.Add(new TextBlock - { - Text = NativeFatPrintPreviewSubtitle, - FontSize = 12, - Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)), - Margin = new Thickness(0, 5, 0, 0) - }); - pageGrid.Children.Add(heading); - - var summary = new Grid - { - Background = new SolidColorBrush(Color.FromRgb(248, 250, 252)), - Margin = new Thickness(0) - }; - summary.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - summary.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var selectedIed = new StackPanel { Margin = new Thickness(14, 10, 14, 10) }; - selectedIed.Children.Add(new TextBlock - { - Text = "SELECTED IED", - FontSize = 9.5, - FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)) - }); - selectedIed.Children.Add(new TextBlock - { - Text = $"{snapshot.IedName} · {snapshot.IpAddress}:{snapshot.Port}", - FontSize = 13, - FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(23, 32, 51)), - Margin = new Thickness(0, 3, 0, 0) - }); - summary.Children.Add(selectedIed); - - var progress = new StackPanel - { - Margin = new Thickness(18, 10, 14, 10), - HorizontalAlignment = HorizontalAlignment.Right - }; - progress.Children.Add(new TextBlock - { - Text = "PROGRESS", - FontSize = 9.5, - FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)), - HorizontalAlignment = HorizontalAlignment.Right - }); - progress.Children.Add(new TextBlock - { - Text = snapshot.ProgressText, - FontSize = 13, - FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(36, 88, 184)), - Margin = new Thickness(0, 3, 0, 0), - HorizontalAlignment = HorizontalAlignment.Right - }); - Grid.SetColumn(progress, 1); - summary.Children.Add(progress); - Grid.SetRow(summary, 2); - pageGrid.Children.Add(summary); - - var grid = new DataGrid - { - ItemsSource = snapshot.Rows, - AutoGenerateColumns = false, - CanUserAddRows = false, - CanUserDeleteRows = false, - CanUserReorderColumns = false, - IsReadOnly = true, - SelectionUnit = DataGridSelectionUnit.FullRow, - EnableRowVirtualization = true, - EnableColumnVirtualization = true, - HeadersVisibility = DataGridHeadersVisibility.Column, - RowHeight = 32, - ColumnHeaderHeight = 34, - GridLinesVisibility = DataGridGridLinesVisibility.Horizontal, - HorizontalGridLinesBrush = new SolidColorBrush(Color.FromRgb(229, 234, 242)), - VerticalGridLinesBrush = Brushes.Transparent, - BorderBrush = new SolidColorBrush(Color.FromRgb(218, 226, 238)), - BorderThickness = new Thickness(1), - Background = Brushes.White - }; - if (TryFindResource("ModernDataGrid") is Style modernDataGrid) - grid.Style = modernDataGrid; - - AddPreviewColumn(grid, "Signal", nameof(NativeFatPrintPreviewRow.Signal), 170); - AddPreviewColumn(grid, "IEC 61850 reference", nameof(NativeFatPrintPreviewRow.IecReference), 285); - AddPreviewColumn(grid, "Type", nameof(NativeFatPrintPreviewRow.Type), 90); - AddPreviewColumn(grid, "Live value", nameof(NativeFatPrintPreviewRow.LiveValue), 115); - AddPreviewColumn(grid, "Value 1", nameof(NativeFatPrintPreviewRow.Value1), 115); - AddPreviewColumn(grid, "Value 2", nameof(NativeFatPrintPreviewRow.Value2), 115); - AddPreviewColumn(grid, "Status", nameof(NativeFatPrintPreviewRow.Status), 110); - AddPreviewColumn(grid, "Result", nameof(NativeFatPrintPreviewRow.Result), 105); - - Grid.SetRow(grid, 4); - pageGrid.Children.Add(grid); - page.Child = pageGrid; - root.Children.Add(page); - preview.Content = root; + // DocumentViewer is the WPF FixedDocument authority for P4D. Its native chrome + // provides pagination, zoom and print without rebuilding the report as a DataGrid. + preview.Content = viewer; preview.Show(); } - - private static void AddPreviewColumn(DataGrid grid, string header, string path, double width) - { - grid.Columns.Add(new DataGridTextColumn - { - Header = header, - Binding = new Binding(path) { Mode = BindingMode.OneWay }, - Width = new DataGridLength(width), - IsReadOnly = true - }); - } } From ab249ba7044be4f15d4dd508e295c67d8cbc9350 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:25:03 +0700 Subject: [PATCH 055/158] test: keep P3 immutable snapshot contract under P4D --- .../NativeFatP3PrintPreviewTests.cs | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs index 82bd0a1a2..3c5cf7828 100644 --- a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs @@ -29,12 +29,12 @@ public void Capture_CopiesSelectedCanonicalRowsInCurrentOrderAndSparseEvidence() Assert.Equal(2, snapshot.Rows.Count); Assert.Equal("Breaker", snapshot.Rows[0].Signal); Assert.Equal("Trip", snapshot.Rows[1].Signal); + Assert.Equal("LD0/XCBR1.Pos.stVal", snapshot.Rows[0].IecTelegram); + Assert.Equal("Good", snapshot.Rows[0].Quality); Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); Assert.Equal("Open [01]", snapshot.Rows[0].Value1); Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); Assert.Equal("PASS", snapshot.Rows[0].Result); - Assert.Equal("COMPLETE", snapshot.Rows[0].Status); - Assert.Equal("WAITING V2", snapshot.Rows[1].Status); Assert.Equal("1/2 complete", snapshot.ProgressText); } @@ -53,17 +53,19 @@ public void Capture_RemainsImmutableWhenLiveRowsAndEvidenceChange() point.Value = "Closed [10]"; point.SignalName = "MUTATED"; + point.Quality = "Questionable"; NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "NEW-V1"); NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, "NEW-V2"); NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "REVIEW"); Assert.Single(snapshot.Rows); Assert.Equal("Breaker", snapshot.Rows[0].Signal); + Assert.Equal("LD0/XCBR1.Pos.stVal", snapshot.Rows[0].IecTelegram); + Assert.Equal("Good", snapshot.Rows[0].Quality); Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); Assert.Equal("Open [01]", snapshot.Rows[0].Value1); Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); Assert.Equal("PASS", snapshot.Rows[0].Result); - Assert.Equal("COMPLETE", snapshot.Rows[0].Status); } [Fact] @@ -85,7 +87,7 @@ public void Capture_ContainsOnlyRequestedDevice() } [Fact] - public void P3_PreviewIsLazySelectedIedOnlyAndKeepsLegacyVisibleContract() + public void P3_PreviewCaptureRemainsLazySelectedIedOnly() { var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); var previewSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); @@ -104,25 +106,10 @@ public void P3_PreviewIsLazySelectedIedOnlyAndKeepsLegacyVisibleContract() Assert.Contains("ShowNativeFatPrintPreview(snapshot)", click, StringComparison.Ordinal); Assert.Contains("SelectedDevice", click, StringComparison.Ordinal); - Assert.Contains("IEC 61850 FAT Evidence Report", previewSource, StringComparison.Ordinal); - Assert.Contains("Static DataSet verification · generic Value 1 / Value 2 evidence · source identity preserved", previewSource, StringComparison.Ordinal); - foreach (var header in new[] - { - "Signal", - "IEC 61850 reference", - "Type", - "Live value", - "Value 1", - "Value 2", - "Status", - "Result" - }) - { - Assert.Contains($"AddPreviewColumn(grid, \"{header}\"", previewSource, StringComparison.Ordinal); - } - Assert.Contains("device.Points.Select", snapshotSource, StringComparison.Ordinal); Assert.Contains("Array.AsReadOnly", snapshotSource, StringComparison.Ordinal); + Assert.Contains("Copy(point.IecTelegram)", snapshotSource, StringComparison.Ordinal); + Assert.Contains("Copy(point.Quality)", snapshotSource, StringComparison.Ordinal); Assert.DoesNotContain("Iec61850MonitorPoint Point", snapshotSource, StringComparison.Ordinal); foreach (var forbidden in new[] From 6964efe435e5d6ef82d3499cb529bc6188c699b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:26:08 +0700 Subject: [PATCH 056/158] test: lock P4D FixedDocument preview contract --- .../NativeFatP4DFixedDocumentPreviewTests.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs new file mode 100644 index 000000000..f89f73fc9 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -0,0 +1,100 @@ +namespace ARSAS.Tests; + +public sealed class NativeFatP4DFixedDocumentPreviewTests +{ + [Fact] + public void P4D_PreviewUsesExistingFixedDocumentAuthorityInsteadOfDataGrid() + { + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); + var renderer = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs")); + + Assert.Contains("NativeFatP4DReportAdapter.Build(snapshot, draft: true)", preview, StringComparison.Ordinal); + Assert.Contains("IoFatReportPreviewDocumentBuilder.Render(layout)", preview, StringComparison.Ordinal); + Assert.Contains("new DocumentViewer", preview, StringComparison.Ordinal); + Assert.Contains("Document = document", preview, StringComparison.Ordinal); + Assert.DoesNotContain("new DataGrid", preview, StringComparison.Ordinal); + Assert.DoesNotContain("AddPreviewColumn", preview, StringComparison.Ordinal); + Assert.DoesNotContain("ItemsSource = snapshot.Rows", preview, StringComparison.Ordinal); + + Assert.Contains("IoFatReportLayoutPlan Build", adapter, StringComparison.Ordinal); + Assert.Contains("public static FixedDocument Render(", renderer, StringComparison.Ordinal); + Assert.Contains("IoFatReportLayoutPlan layout", renderer, StringComparison.Ordinal); + Assert.Contains("new FixedDocument()", renderer, StringComparison.Ordinal); + } + + [Fact] + public void P4D_ReportAdapterLocksExactP4CColumns() + { + var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); + var snapshot = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); + + var signal = adapter.IndexOf("\"Signal\"", StringComparison.Ordinal); + var telegram = adapter.IndexOf("\"IEC Telegram\"", StringComparison.Ordinal); + var quality = adapter.IndexOf("\"Quality\"", StringComparison.Ordinal); + var live = adapter.IndexOf("\"Live Value\"", StringComparison.Ordinal); + var value1 = adapter.IndexOf("\"Value 1\"", StringComparison.Ordinal); + var value2 = adapter.IndexOf("\"Value 2\"", StringComparison.Ordinal); + var result = adapter.IndexOf("\"Result\"", StringComparison.Ordinal); + + Assert.True(signal >= 0); + Assert.True(telegram > signal); + Assert.True(quality > telegram); + Assert.True(live > quality); + Assert.True(value1 > live); + Assert.True(value2 > value1); + Assert.True(result > value2); + + Assert.DoesNotContain("\"Type\"", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("\"Status\"", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("\"Timestamp\"", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("\"IEC 61850 reference\"", adapter, StringComparison.Ordinal); + + Assert.Contains("string IecTelegram", snapshot, StringComparison.Ordinal); + Assert.Contains("string Quality", snapshot, StringComparison.Ordinal); + Assert.DoesNotContain("string Type", snapshot, StringComparison.Ordinal); + Assert.DoesNotContain("string Status", snapshot, StringComparison.Ordinal); + } + + [Fact] + public void P4D_PreviewDoesNotReintroduceRuntimeOrSclBootstrap() + { + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); + + foreach (var forbidden in new[] + { + "ConnectAndDiscoverAsync", + "StartMonitoringAsync", + "PrepareIoTestIedForFatAsync", + "OpenDescribedSourcesAsync", + "IoFatEngineeringWorkspaceProjectionService", + "FatSclWorkspaceImportService" + }) + { + Assert.DoesNotContain(forbidden, preview, StringComparison.Ordinal); + Assert.DoesNotContain(forbidden, adapter, StringComparison.Ordinal); + } + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From 6b5d4c90e009c2165aa5d3081d9152d572fde38a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:27:14 +0700 Subject: [PATCH 057/158] test: align P1 visual contract with P4C Live Value authority --- .../ProductionFatP1CanonicalGridRegressionTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs index 3ffc6992f..ecff1b667 100644 --- a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs @@ -39,6 +39,7 @@ public void P1B_EvidenceColumnsRemainSparseOverlayNotRowWrappers() public void P1C_NativeFatUsesEngineeringGridStyleTemplateAndVirtualizationContract() { var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var columnContract = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); var engineeringXaml = File.ReadAllText(FindRepoFile("MainWindow.xaml")); var appXaml = File.ReadAllText(FindRepoFile("App.xaml")); @@ -51,7 +52,8 @@ public void P1C_NativeFatUsesEngineeringGridStyleTemplateAndVirtualizationContra Assert.Contains("VirtualizingPanel.VirtualizationMode=\"Recycling\"", engineeringXaml, StringComparison.Ordinal); Assert.Contains("FindResource(\"ModernDataGrid\") as Style", gridSource, StringComparison.Ordinal); - Assert.Contains("AddCanonicalTemplateColumn(\"Value\", \"ProcessValueBadgeTemplate\", 125);", gridSource, StringComparison.Ordinal); + Assert.Contains("ApplyNativeFatP4CColumnContract();", gridSource, StringComparison.Ordinal); + Assert.Contains("AddCanonicalTemplateColumn(\"Live Value\", \"ProcessValueBadgeTemplate\", 140);", columnContract, StringComparison.Ordinal); Assert.Contains("VirtualizingPanel.SetVirtualizationMode(_nativeFatCanonicalGrid, VirtualizationMode.Recycling);", gridSource, StringComparison.Ordinal); Assert.Contains("RowStyle = BuildEngineeringLiveRowStyle()", gridSource, StringComparison.Ordinal); Assert.Contains("CellStyle = BuildEngineeringLiveCellStyle()", gridSource, StringComparison.Ordinal); From 9ebe0a9ed514c6f644402c4450fee56da1376e5e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:32:30 +0700 Subject: [PATCH 058/158] FAT P4D: preserve timestamped evidence in report snapshot --- Services/IoTesting/NativeFatPrintPreviewSnapshot.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index ab13e9893..1d539599e 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -55,13 +55,15 @@ public static NativeFatPrintPreviewSnapshot Capture( // Materialize in the current canonical Engineering row order. Every value below // is copied now; P4D never binds back to device.Points or EvidenceByRow. + // Value 1/2 deliberately use the exact operator-facing P4B display text so the + // relay/ARSAS timestamp visible in FAT is preserved in Print Preview and PDF. var rows = device.Points.Select(point => { - var value1 = NativeFatCanonicalEvidenceOverlay.ReadRaw( + var value1 = NativeFatCanonicalEvidenceOverlay.ReadDisplay( cache, point, NativeFatEvidenceField.Value1).Trim(); - var value2 = NativeFatCanonicalEvidenceOverlay.ReadRaw( + var value2 = NativeFatCanonicalEvidenceOverlay.ReadDisplay( cache, point, NativeFatEvidenceField.Value2).Trim(); From dcec5366ad8e56bc63add018844a93843764e80c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:33:04 +0700 Subject: [PATCH 059/158] FAT P4D: add layout-first native PDF serialization --- Services/IoTesting/IoFatNativePdfWriter.cs | 36 +++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/Services/IoTesting/IoFatNativePdfWriter.cs b/Services/IoTesting/IoFatNativePdfWriter.cs index dc55ae4a1..fbc5a0d5b 100644 --- a/Services/IoTesting/IoFatNativePdfWriter.cs +++ b/Services/IoTesting/IoFatNativePdfWriter.cs @@ -17,11 +17,36 @@ internal static class IoFatNativePdfWriter { public static byte[] Build(IoFatReportLayoutPlan layout, IoTestProject project) { - ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(project); + var primaryReference = project.Ieds + .SelectMany(ied => ied.TestPoints) + .Select(point => point.ObjectReference) + .FirstOrDefault(reference => !string.IsNullOrWhiteSpace(reference)) + ?? project.ProjectId; + return Build(layout, project.ProjectName, primaryReference); + } + + /// + /// P4D layout-first PDF path. Native FAT already owns an immutable canonical snapshot, + /// so PDF serialization receives the exact same layout instance as DocumentViewer and + /// needs only document metadata, never a reconstructed IoTestProject/runtime workspace. + /// + public static byte[] Build( + IoFatReportLayoutPlan layout, + string reportName, + string primaryReference) + { + ArgumentNullException.ThrowIfNull(layout); if (layout.Pages.Count == 0) throw new InvalidOperationException("At least one PDF page is required."); + var safeReportName = string.IsNullOrWhiteSpace(reportName) + ? "ARSAS FAT" + : reportName.Trim(); + var safePrimaryReference = string.IsNullOrWhiteSpace(primaryReference) + ? layout.ProjectId + : primaryReference.Trim(); + var fonts = IoFatReportTypography.ResolvePdfFonts(); var objects = new List(); @@ -52,13 +77,8 @@ int AddObjectBytes(byte[] bytes) pageIds.Add(pageId); } - var primaryReference = project.Ieds - .SelectMany(ied => ied.TestPoints) - .Select(point => point.ObjectReference) - .FirstOrDefault(reference => !string.IsNullOrWhiteSpace(reference)) - ?? project.ProjectId; - var title = $"{project.ProjectName} - IEC 61850 FAT Evidence Report"; - var subject = $"Customer-readable FAT summary. Detailed evidence is retained in the ARSAS project and Excel export. Primary IEC 61850 reference: {primaryReference}"; + var title = $"{safeReportName} - IEC 61850 FAT Evidence Report"; + var subject = $"Immutable IEC 61850 FAT evidence report. Primary IEC 61850 reference: {safePrimaryReference}"; var infoId = AddObject( $"<< /Title ({EscapeLiteral(IoFatReportLayoutEngine.SanitizeReportText(title))}) " + $"/Subject ({EscapeLiteral(IoFatReportLayoutEngine.SanitizeReportText(subject))}) " + From 47df5d4e04f491b74ca0fe77e4016eb5d650aba5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:33:17 +0700 Subject: [PATCH 060/158] FAT P4D: save immutable layout through shared PDF service --- Services/IoTesting/IoFatPdfReportService.cs | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Services/IoTesting/IoFatPdfReportService.cs b/Services/IoTesting/IoFatPdfReportService.cs index f43c76f74..8f2e640b0 100644 --- a/Services/IoTesting/IoFatPdfReportService.cs +++ b/Services/IoTesting/IoFatPdfReportService.cs @@ -46,10 +46,40 @@ internal static IoFatReportLayoutPlan BuildLayout( return IoFatSupplementalReportLayoutDecorator.AppendFileServiceEvidence(reportProject, layout); } + /// + /// P4D native FAT export. The immutable selected-IED snapshot has already been mapped + /// to one report layout plan, so both DocumentViewer and PDF serialize that same plan. + /// No IoTestProject/runtime workspace is rebuilt for export. + /// + internal static byte[] GenerateLayout( + IoFatReportLayoutPlan layout, + string reportName, + string primaryReference) + { + ArgumentNullException.ThrowIfNull(layout); + return IoFatNativePdfWriter.Build(layout, reportName, primaryReference); + } + public static void Save(string fileName, IoTestProject project, DateTimeOffset? generatedAt = null) { ArgumentException.ThrowIfNullOrWhiteSpace(fileName); var bytes = Generate(project, generatedAt); + SaveBytesAtomic(fileName, bytes); + } + + internal static void SaveLayout( + string fileName, + IoFatReportLayoutPlan layout, + string reportName, + string primaryReference) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + var bytes = GenerateLayout(layout, reportName, primaryReference); + SaveBytesAtomic(fileName, bytes); + } + + private static void SaveBytesAtomic(string fileName, byte[] bytes) + { var fullPath = Path.GetFullPath(fileName); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); var temporary = fullPath + ".tmp-" + Guid.NewGuid().ToString("N"); From f4edbe0ca0fd486d54a66ab92f6e4de037d9b869 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:33:43 +0700 Subject: [PATCH 061/158] FAT P4D: add Save PDF parity to FixedDocument preview --- MainWindow.NativeFatPrintPreview.cs | 105 +++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs index d589e201c..a7a7e9c0f 100644 --- a/MainWindow.NativeFatPrintPreview.cs +++ b/MainWindow.NativeFatPrintPreview.cs @@ -2,6 +2,7 @@ using System.Windows.Controls; using System.Windows.Media; using ArIED61850Tester.Services.IoTesting; +using Microsoft.Win32; namespace ArIED61850Tester; @@ -35,8 +36,9 @@ private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) /// /// P4D preview path: immutable selected-IED snapshot -> thin report layout adapter -> - /// existing FixedDocument renderer -> DocumentViewer. No live row, evidence cache, - /// SCL import, discovery, reconnect, or second acquisition engine is retained here. + /// existing FixedDocument renderer -> DocumentViewer. Preview and Save PDF consume the + /// exact same IoFatReportLayoutPlan instance. No live row, evidence cache, SCL import, + /// discovery, reconnect, or second acquisition engine is retained here. /// private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) { @@ -57,6 +59,45 @@ private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) Background = new SolidColorBrush(Color.FromRgb(232, 237, 244)) }; + var root = new Grid(); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var toolbar = new Border + { + Padding = new Thickness(14, 10, 14, 10), + Background = Brushes.White, + BorderBrush = new SolidColorBrush(Color.FromRgb(216, 224, 234)), + BorderThickness = new Thickness(0, 0, 0, 1) + }; + var toolbarGrid = new Grid(); + toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var summary = new TextBlock + { + Text = $"{snapshot.IedName} · {snapshot.Rows.Count} row(s) · {snapshot.ProgressText} · captured {snapshot.CapturedAt:yyyy-MM-dd HH:mm:ss zzz}", + VerticalAlignment = VerticalAlignment.Center, + FontSize = 11.5, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(51, 65, 85)) + }; + toolbarGrid.Children.Add(summary); + + var savePdfButton = new Button + { + Content = "Save PDF", + MinWidth = 96, + Padding = new Thickness(14, 7, 14, 7), + Style = TryFindResource("PrimaryButton") as Style, + ToolTip = "Save this exact immutable preview layout as PDF." + }; + savePdfButton.Click += (_, _) => SaveNativeFatPreviewPdf(preview, snapshot, layout); + Grid.SetColumn(savePdfButton, 1); + toolbarGrid.Children.Add(savePdfButton); + toolbar.Child = toolbarGrid; + root.Children.Add(toolbar); + var viewer = new DocumentViewer { Document = document, @@ -64,10 +105,68 @@ private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Stretch }; + Grid.SetRow(viewer, 1); + root.Children.Add(viewer); // DocumentViewer is the WPF FixedDocument authority for P4D. Its native chrome // provides pagination, zoom and print without rebuilding the report as a DataGrid. - preview.Content = viewer; + preview.Content = root; preview.Show(); } + + private void SaveNativeFatPreviewPdf( + Window owner, + NativeFatPrintPreviewSnapshot snapshot, + IoFatReportLayoutPlan layout) + { + var dialog = new SaveFileDialog + { + Title = "Save IEC 61850 FAT Evidence Report", + Filter = "PDF document (*.pdf)|*.pdf", + AddExtension = true, + DefaultExt = ".pdf", + OverwritePrompt = true, + FileName = BuildNativeFatPdfFileName(snapshot.IedName) + }; + + if (dialog.ShowDialog(owner) != true) + return; + + try + { + var primaryReference = snapshot.Rows + .Select(row => row.IecTelegram) + .FirstOrDefault(reference => !string.IsNullOrWhiteSpace(reference)) + ?? snapshot.DeviceId; + + // Critical P4D invariant: serialize the exact layout already rendered above. + // Do not rebuild a project, snapshot, row list, SCL model, or report layout here. + IoFatPdfReportService.SaveLayout( + dialog.FileName, + layout, + snapshot.IedName, + primaryReference); + SetStatus($"FAT · PDF saved · {dialog.FileName}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + SetStatus($"FAT · PDF save failed · {ex.Message}"); + MessageBox.Show( + owner, + ex.Message, + "Save PDF", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + + private static string BuildNativeFatPdfFileName(string? iedName) + { + var source = string.IsNullOrWhiteSpace(iedName) ? "IED" : iedName.Trim(); + var invalid = Path.GetInvalidFileNameChars(); + var safe = new string(source.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); + if (safe.Length == 0) + safe = "IED"; + return $"{safe}-FAT-Evidence.pdf"; + } } From 8b814dd17c34324d7c811a1533561d267caaf957 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:34:38 +0700 Subject: [PATCH 062/158] test: align immutable preview with timestamped P4B evidence --- tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs index 3c5cf7828..919f27d03 100644 --- a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs @@ -32,8 +32,8 @@ public void Capture_CopiesSelectedCanonicalRowsInCurrentOrderAndSparseEvidence() Assert.Equal("LD0/XCBR1.Pos.stVal", snapshot.Rows[0].IecTelegram); Assert.Equal("Good", snapshot.Rows[0].Quality); Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); - Assert.Equal("Open [01]", snapshot.Rows[0].Value1); - Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); + Assert.StartsWith("Open [01] - ", snapshot.Rows[0].Value1, StringComparison.Ordinal); + Assert.StartsWith("Closed [10] - ", snapshot.Rows[0].Value2, StringComparison.Ordinal); Assert.Equal("PASS", snapshot.Rows[0].Result); Assert.Equal("1/2 complete", snapshot.ProgressText); } @@ -50,6 +50,8 @@ public void Capture_RemainsImmutableWhenLiveRowsAndEvidenceChange() NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "PASS"); var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); + var capturedValue1 = snapshot.Rows[0].Value1; + var capturedValue2 = snapshot.Rows[0].Value2; point.Value = "Closed [10]"; point.SignalName = "MUTATED"; @@ -63,8 +65,10 @@ public void Capture_RemainsImmutableWhenLiveRowsAndEvidenceChange() Assert.Equal("LD0/XCBR1.Pos.stVal", snapshot.Rows[0].IecTelegram); Assert.Equal("Good", snapshot.Rows[0].Quality); Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); - Assert.Equal("Open [01]", snapshot.Rows[0].Value1); - Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); + Assert.Equal(capturedValue1, snapshot.Rows[0].Value1); + Assert.Equal(capturedValue2, snapshot.Rows[0].Value2); + Assert.StartsWith("Open [01] - ", snapshot.Rows[0].Value1, StringComparison.Ordinal); + Assert.StartsWith("Closed [10] - ", snapshot.Rows[0].Value2, StringComparison.Ordinal); Assert.Equal("PASS", snapshot.Rows[0].Result); } @@ -110,6 +114,7 @@ public void P3_PreviewCaptureRemainsLazySelectedIedOnly() Assert.Contains("Array.AsReadOnly", snapshotSource, StringComparison.Ordinal); Assert.Contains("Copy(point.IecTelegram)", snapshotSource, StringComparison.Ordinal); Assert.Contains("Copy(point.Quality)", snapshotSource, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadDisplay", snapshotSource, StringComparison.Ordinal); Assert.DoesNotContain("Iec61850MonitorPoint Point", snapshotSource, StringComparison.Ordinal); foreach (var forbidden in new[] From b13a89b72ecbaacca0909c7cd2111d13e2501b48 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:34:57 +0700 Subject: [PATCH 063/158] test: close P4D preview and PDF parity contract --- .../NativeFatP4DFixedDocumentPreviewTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs index f89f73fc9..546bcca4e 100644 --- a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -1,3 +1,7 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + namespace ARSAS.Tests; public sealed class NativeFatP4DFixedDocumentPreviewTests @@ -23,6 +27,69 @@ public void P4D_PreviewUsesExistingFixedDocumentAuthorityInsteadOfDataGrid() Assert.Contains("new FixedDocument()", renderer, StringComparison.Ordinal); } + [Fact] + public void P4D_SavePdfSerializesTheExactLayoutAlreadyRenderedInPreview() + { + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var pdfService = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatPdfReportService.cs")); + var pdfWriter = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatNativePdfWriter.cs")); + + Assert.Contains("var layout = NativeFatP4DReportAdapter.Build(snapshot, draft: true);", preview, StringComparison.Ordinal); + Assert.Contains("IoFatReportPreviewDocumentBuilder.Render(layout)", preview, StringComparison.Ordinal); + Assert.Contains("Content = \"Save PDF\"", preview, StringComparison.Ordinal); + Assert.Contains("IoFatPdfReportService.SaveLayout(", preview, StringComparison.Ordinal); + Assert.Contains("layout,", preview, StringComparison.Ordinal); + Assert.Contains("internal static void SaveLayout(", pdfService, StringComparison.Ordinal); + Assert.Contains("GenerateLayout(layout, reportName, primaryReference)", pdfService, StringComparison.Ordinal); + Assert.Contains("IoFatNativePdfWriter.Build(layout, reportName, primaryReference)", pdfService, StringComparison.Ordinal); + Assert.Contains("public static byte[] Build(\n IoFatReportLayoutPlan layout,\n string reportName,", pdfWriter, StringComparison.Ordinal); + } + + [Fact] + public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild() + { + var device = new Iec61850MonitorDevice + { + DeviceId = "runtime-p4d", + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102 + }; + var point = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + SignalName = "Breaker", + IecReference = "AA1E1F06R4LD0/XCBR1.Pos.stVal", + Quality = "Good", + Value = "Open [01]" + }; + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value1, + "Open [01]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.OperatorSnapshot, + DateTimeOffset.UtcNow); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value2, + "Closed [10]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.OperatorSnapshot, + DateTimeOffset.UtcNow); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); + var layout = NativeFatP4DReportAdapter.Build(snapshot, draft: true); + var bytes = IoFatPdfReportService.GenerateLayout(layout, snapshot.IedName, snapshot.Rows[0].IecTelegram); + + Assert.NotEmpty(layout.Pages); + Assert.True(bytes.Length > 32); + Assert.Equal("%PDF-1.4", Encoding.ASCII.GetString(bytes, 0, 8)); + } + [Fact] public void P4D_ReportAdapterLocksExactP4CColumns() { @@ -52,6 +119,7 @@ public void P4D_ReportAdapterLocksExactP4CColumns() Assert.Contains("string IecTelegram", snapshot, StringComparison.Ordinal); Assert.Contains("string Quality", snapshot, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadDisplay", snapshot, StringComparison.Ordinal); Assert.DoesNotContain("string Type", snapshot, StringComparison.Ordinal); Assert.DoesNotContain("string Status", snapshot, StringComparison.Ordinal); } From f9d248ed07893a8c177863786b3b7efba87b2400 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:35:35 +0700 Subject: [PATCH 064/158] test: start P4E evidence isolation regression gauntlet --- ...eFatP4EEvidenceIsolationRegressionTests.cs | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs diff --git a/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs b/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs new file mode 100644 index 000000000..ff03a8708 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs @@ -0,0 +1,289 @@ +using System.Text.Json; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP4EEvidenceIsolationRegressionTests +{ + [Fact] + public async Task P4E_RestartReorder_CswiEvidenceStaysOnExactTelegramAndNeverMovesToThdPpv() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var before = Device("runtime-before", "AA1E1F06R4"); + var cswiBefore = Point( + before, + "Breaker position", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]", + "BOOLEAN"); + var thdBefore = Point( + before, + "THD phase voltage", + "AA1E1F06R4LD0/MMXU1.ThdPPV.phsA.cVal.mag.f", + "2.20", + "FLOAT32"); + before.Points.Add(cswiBefore); + before.Points.Add(thdBefore); + + var saved = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + saved, + cswiBefore, + NativeFatEvidenceField.Value1, + "Open [01]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.UtcNow); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + saved, + cswiBefore, + NativeFatEvidenceField.Value2, + "Closed [10]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.UtcNow); + await service.SaveAsync(before, saved); + + // Simulate application/runtime recreation plus the exact row-order inversion that + // previously allowed CSWI evidence to appear on an unrelated THD row. + var after = Device("runtime-after", "AA1E1F06R4"); + var thdAfter = Point( + after, + "THD renamed after restart", + "AA1E1F06R4LD0/MMXU1.ThdPPV.phsA.cVal.mag.f", + "2.25", + "FLOAT32"); + var cswiAfter = Point( + after, + "Breaker renamed after restart", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Closed [10]", + "BOOLEAN"); + after.Points.Add(thdAfter); + after.Points.Add(cswiAfter); + + var hydration = await service.HydrateAsync(after); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); + + Assert.True(hydration.Succeeded); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value2)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, thdAfter, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, thdAfter, NativeFatEvidenceField.Value2)); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(after, restored); + Assert.Equal(2, snapshot.Rows.Count); + Assert.Equal("LD0/MMXU1.ThdPPV.phsA.cVal.mag.f", snapshot.Rows[0].IecTelegram); + Assert.Equal("LD0/CSWI1.Pos.stVal", snapshot.Rows[1].IecTelegram); + Assert.Equal("—", snapshot.Rows[0].Value1); + Assert.StartsWith("Open [01] - ", snapshot.Rows[1].Value1, StringComparison.Ordinal); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void P4E_TwoIedsWithSameSignalNameCannotCrossContaminateEvidence() + { + var cache = new NativeFatIedSessionCacheState(); + var iedA = Device("runtime-a", "IED_A"); + var iedB = Device("runtime-b", "IED_B"); + var pointA = Point(iedA, "Breaker position", "IED_ALD0/CSWI1.Pos.stVal", "Open [01]", "BOOLEAN"); + var pointB = Point(iedB, "Breaker position", "IED_BLD0/CSWI1.Pos.stVal", "Closed [10]", "BOOLEAN"); + + NativeFatCanonicalEvidenceOverlay.Write(cache, pointA, NativeFatEvidenceField.Result, "PASS-A"); + NativeFatCanonicalEvidenceOverlay.Write(cache, pointB, NativeFatEvidenceField.Result, "PASS-B"); + + Assert.NotEqual( + NativeFatCanonicalEvidenceOverlay.BuildRowKey(pointA), + NativeFatCanonicalEvidenceOverlay.BuildRowKey(pointB)); + Assert.Equal("PASS-A", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, pointA, NativeFatEvidenceField.Result)); + Assert.Equal("PASS-B", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, pointB, NativeFatEvidenceField.Result)); + Assert.Equal(2, cache.EvidenceByRow.Count); + } + + [Fact] + public async Task P4E_UnknownPersistedTelegramIsIgnoredAsOrphanAndNeverRemappedByPositionOrName() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var device = Device("runtime-current", "AA1E1F06R4"); + var cswi = Point(device, "Same display name", "AA1E1F06R4LD0/CSWI1.Pos.stVal", "Open [01]", "BOOLEAN"); + device.Points.Add(cswi); + + Directory.CreateDirectory(root); + var orphanDocument = new + { + schema = "ARSAS-NATIVE-FAT-EVIDENCE-2.0", + savedAtUtc = DateTimeOffset.UtcNow, + deviceId = "old-runtime", + deviceName = device.Name, + ipAddress = device.IpAddress, + evidenceByRow = new Dictionary + { + ["aa1e1f06r4|ld0/unknown1.pos.stval"] = new + { + value1 = "SHOULD-NOT-MOVE", + value2 = "", + result = "PASS" + } + } + }; + await File.WriteAllTextAsync( + service.SnapshotPath(device.Name), + JsonSerializer.Serialize(orphanDocument)); + + var hydration = await service.HydrateAsync(device); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); + + Assert.True(hydration.Succeeded); + Assert.True(hydration.SnapshotFound); + Assert.Equal(0, hydration.LoadedRows); + Assert.Equal(1, hydration.IgnoredRows); + Assert.Empty(hydration.EvidenceByRow); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswi, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswi, NativeFatEvidenceField.Result)); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData("BOOLEAN", "True")] + [InlineData("FLOAT32", "1247.32 A")] + [InlineData("DbPos", "Closed [10]")] + [InlineData("INT32", "Tap 7")] + public void P4E_DigitalAnalogPositionAndTapEvidenceKeepMillisecondTimestamp(string dataType, string rawValue) + { + var device = Device("runtime-types", "AA1E1F06R4"); + var point = Point(device, "Evidence", "AA1E1F06R4LD0/GGIO1.Test.stVal", rawValue, dataType); + point.DeviceTimestamp = "2026-09-12T06:46:31.958+07:00"; + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value1, + rawValue, + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.UtcNow); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); + Assert.Equal($"{rawValue} - 2026-09-12 06:46:31.958", snapshot.Rows[0].Value1); + } + + [Fact] + public void P4E_PrintPreviewRowCountAndOrderExactlyFollowCanonicalExplorerRows() + { + var device = Device("runtime-order", "AA1E1F06R4"); + device.Points.Add(Point(device, "Third", "AA1E1F06R4LD0/GGIO1.Ind3.stVal", "False", "BOOLEAN")); + device.Points.Add(Point(device, "First", "AA1E1F06R4LD0/GGIO1.Ind1.stVal", "True", "BOOLEAN")); + device.Points.Add(Point(device, "Second", "AA1E1F06R4LD0/GGIO1.Ind2.stVal", "False", "BOOLEAN")); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, new NativeFatIedSessionCacheState()); + + Assert.Equal(device.Points.Count, snapshot.Rows.Count); + Assert.Equal( + device.Points.Select(point => point.IecTelegram), + snapshot.Rows.Select(row => row.IecTelegram)); + Assert.Equal( + device.Points.Select(point => point.SignalName), + snapshot.Rows.Select(row => row.Signal)); + } + + [Fact] + public void P4E_RecycledEvidenceColumnReadsTheCurrentRowObjectNotIndexOrSignalName() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var classStart = source.IndexOf("private sealed class NativeFatEvidenceColumn", StringComparison.Ordinal); + Assert.True(classStart >= 0); + var evidenceColumn = source[classStart..]; + + Assert.Contains("dataItem is Iec61850MonitorPoint point", evidenceColumn, StringComparison.Ordinal); + Assert.Contains("_owner.ReadNativeFatEvidence(point, Field)", evidenceColumn, StringComparison.Ordinal); + Assert.DoesNotContain("SelectedIndex", evidenceColumn, StringComparison.Ordinal); + Assert.DoesNotContain("SignalName", evidenceColumn, StringComparison.Ordinal); + Assert.DoesNotContain("Items.IndexOf", evidenceColumn, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(string deviceId, string name) + => new() + { + DeviceId = deviceId, + Name = name, + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + Iec61850MonitorDevice device, + string signalName, + string reference, + string value, + string dataType) + => new() + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + SignalName = signalName, + IecReference = reference, + IecDataType = dataType, + Quality = "Good", + Status = "Live", + SourceMode = "Static DataSet reporting", + Value = value + }; + + private static string TempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "arsas-native-fat-p4e-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + } + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From 220285c6090eee707b4e4a7a294c6a9d92803cd6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:44:08 +0700 Subject: [PATCH 065/158] FAT P4E: add exact command feedback correlation --- .../NativeFatCommandFeedbackCorrelation.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 Services/IoTesting/NativeFatCommandFeedbackCorrelation.cs diff --git a/Services/IoTesting/NativeFatCommandFeedbackCorrelation.cs b/Services/IoTesting/NativeFatCommandFeedbackCorrelation.cs new file mode 100644 index 000000000..8ebcb3dd3 --- /dev/null +++ b/Services/IoTesting/NativeFatCommandFeedbackCorrelation.cs @@ -0,0 +1,63 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// P4E correlation boundary between an already-executed IEC 61850 control request and +/// the canonical Engineering feedback row. Correlation is identity-only: the control +/// model's explicit StatusReference is converted to IEDName + IEC Telegram and must match +/// exactly one canonical row. Missing, cross-IED, or duplicate matches fail closed. +/// +/// This service owns no command transport, reconnect, polling, SCL parsing, or live rows. +/// +internal static class NativeFatCommandFeedbackCorrelation +{ + internal static Iec61850MonitorPoint? Resolve( + Iec61850MonitorDevice device, + Iec61850ControlCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(capabilities); + + return Resolve(device, capabilities.StatusReference); + } + + internal static Iec61850MonitorPoint? Resolve( + Iec61850MonitorDevice device, + string? statusReference) + { + ArgumentNullException.ThrowIfNull(device); + if (string.IsNullOrWhiteSpace(device.Name) || string.IsNullOrWhiteSpace(statusReference)) + return null; + + var feedbackTelegram = Iec61850MonitorPoint.StripIedNamePrefix( + statusReference, + device.Name); + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey( + device.Name, + feedbackTelegram, + out var expectedKey)) + { + return null; + } + + Iec61850MonitorPoint? match = null; + foreach (var point in device.Points) + { + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var candidateKey) || + !string.Equals(candidateKey, expectedKey, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Ambiguous canonical identity is unsafe for FAT evidence correlation. + // Never pick first/last/index ordering as a tiebreaker. + if (match != null) + return null; + + match = point; + } + + return match; + } +} From 712575cd3d485725b6e0b28e2c2fad27cf075f5f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:44:24 +0700 Subject: [PATCH 066/158] test: lock P4E command feedback identity correlation --- ...veFatP4ECommandFeedbackCorrelationTests.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs diff --git a/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs b/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs new file mode 100644 index 000000000..d8e57bfd9 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs @@ -0,0 +1,146 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP4ECommandFeedbackCorrelationTests +{ + [Fact] + public void ExactStatusReference_ResolvesCanonicalFeedbackAndNotUnrelatedThdRow() + { + var device = Device("AA1E1F06R4"); + var thd = Point(device, "THD", "AA1E1F06R4LD0/MMXU1.ThdPPV.phsA.cVal.mag.f"); + var feedback = Point(device, "Breaker feedback", "AA1E1F06R4LD0/CSWI1.Pos.stVal"); + device.Points.Add(thd); + device.Points.Add(feedback); + var capabilities = new Iec61850ControlCapabilities + { + ObjectReference = "AA1E1F06R4LD0/CSWI1.Pos.Oper", + StatusReference = "AA1E1F06R4LD0/CSWI1.Pos.stVal", + ControlModel = Iec61850ControlModelKind.DirectNormal, + EngineControlServiceAvailable = true, + IsOperationallyReady = true + }; + + var resolved = NativeFatCommandFeedbackCorrelation.Resolve(device, capabilities); + + Assert.Same(feedback, resolved); + Assert.NotSame(thd, resolved); + } + + [Fact] + public void MissingStatusReference_FailsClosedWithoutObjectReferenceFallback() + { + var device = Device("AA1E1F06R4"); + device.Points.Add(Point(device, "Breaker feedback", "AA1E1F06R4LD0/CSWI1.Pos.stVal")); + var capabilities = new Iec61850ControlCapabilities + { + ObjectReference = "AA1E1F06R4LD0/CSWI1.Pos.Oper", + StatusReference = string.Empty, + ControlModel = Iec61850ControlModelKind.DirectNormal, + EngineControlServiceAvailable = true, + IsOperationallyReady = true + }; + + Assert.Null(NativeFatCommandFeedbackCorrelation.Resolve(device, capabilities)); + } + + [Fact] + public void DuplicateCanonicalFeedbackIdentity_FailsClosedInsteadOfChoosingByOrder() + { + var device = Device("AA1E1F06R4"); + device.Points.Add(Point(device, "Feedback A", "AA1E1F06R4LD0/CSWI1.Pos.stVal")); + device.Points.Add(Point(device, "Feedback B", "AA1E1F06R4LD0/CSWI1.Pos.stVal")); + + Assert.Null(NativeFatCommandFeedbackCorrelation.Resolve( + device, + "AA1E1F06R4LD0/CSWI1.Pos.stVal")); + } + + [Fact] + public void SameSignalNameOnDifferentTelegram_CannotInfluenceCorrelation() + { + var device = Device("AA1E1F06R4"); + var wrong = Point(device, "Breaker position", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); + var expected = Point(device, "Breaker position", "AA1E1F06R4LD0/CSWI1.Pos.stVal"); + device.Points.Add(wrong); + device.Points.Add(expected); + + var resolved = NativeFatCommandFeedbackCorrelation.Resolve( + device, + "AA1E1F06R4LD0/CSWI1.Pos.stVal"); + + Assert.Same(expected, resolved); + } + + [Fact] + public void StatusReferenceFromAnotherIed_DoesNotCrossContaminateCurrentDevice() + { + var device = Device("IED_A"); + device.Points.Add(Point(device, "Breaker feedback", "IED_ALD0/CSWI1.Pos.stVal")); + + Assert.Null(NativeFatCommandFeedbackCorrelation.Resolve( + device, + "IED_BLD0/CSWI1.Pos.stVal")); + } + + [Fact] + public void SourceContract_HasNoDisplayIndexOrRuntimeIdentityFallback() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCommandFeedbackCorrelation.cs")); + + Assert.Contains("capabilities.StatusReference", source, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.TryBuildRowKey", source, StringComparison.Ordinal); + Assert.Contains("if (match != null)", source, StringComparison.Ordinal); + Assert.DoesNotContain("SignalName", source, StringComparison.Ordinal); + Assert.DoesNotContain("SelectedIndex", source, StringComparison.Ordinal); + Assert.DoesNotContain("Items.IndexOf", source, StringComparison.Ordinal); + Assert.DoesNotContain("DeviceId", source, StringComparison.Ordinal); + Assert.DoesNotContain("ObjectReference", source, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(string name) + => new() + { + DeviceId = "runtime-" + name, + Name = name, + IpAddress = "192.168.81.103", + Port = 102 + }; + + private static Iec61850MonitorPoint Point( + Iec61850MonitorDevice device, + string signalName, + string reference) + => new() + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Value = "False" + }; + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From 9ec392d0db82f5623cf96e2716927316e362527b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 10:44:55 +0700 Subject: [PATCH 067/158] test: make P4D PDF source contract line-ending agnostic --- tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs index 546bcca4e..c0a328aa1 100644 --- a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -42,7 +42,10 @@ public void P4D_SavePdfSerializesTheExactLayoutAlreadyRenderedInPreview() Assert.Contains("internal static void SaveLayout(", pdfService, StringComparison.Ordinal); Assert.Contains("GenerateLayout(layout, reportName, primaryReference)", pdfService, StringComparison.Ordinal); Assert.Contains("IoFatNativePdfWriter.Build(layout, reportName, primaryReference)", pdfService, StringComparison.Ordinal); - Assert.Contains("public static byte[] Build(\n IoFatReportLayoutPlan layout,\n string reportName,", pdfWriter, StringComparison.Ordinal); + Assert.Contains("public static byte[] Build(", pdfWriter, StringComparison.Ordinal); + Assert.Contains("IoFatReportLayoutPlan layout,", pdfWriter, StringComparison.Ordinal); + Assert.Contains("string reportName,", pdfWriter, StringComparison.Ordinal); + Assert.Contains("string primaryReference)", pdfWriter, StringComparison.Ordinal); } [Fact] From af9c572c8b78328cdb90f42ef2ea1ffc3d4487f3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:07:21 +0700 Subject: [PATCH 068/158] test: align P1 sparse evidence contract with P4C column authority --- .../ProductionFatP1CanonicalGridRegressionTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs index ecff1b667..e8acb770e 100644 --- a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs @@ -22,13 +22,16 @@ public void P1A_NormalFatEntryBindsExactEngineeringPointCollection() public void P1B_EvidenceColumnsRemainSparseOverlayNotRowWrappers() { var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var columnContract = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); var overlaySource = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); - Assert.Contains("NativeFatEvidenceField.Value1", gridSource, StringComparison.Ordinal); - Assert.Contains("NativeFatEvidenceField.Value2", gridSource, StringComparison.Ordinal); - Assert.Contains("NativeFatEvidenceField.Result", gridSource, StringComparison.Ordinal); + // P4C is the single column authority; evidence field declarations intentionally + // live there rather than in the canonical grid builder. + Assert.Contains("NativeFatEvidenceField.Value1", columnContract, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2", columnContract, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Result", columnContract, StringComparison.Ordinal); Assert.Contains("NativeFatIedSessionCacheState", gridSource, StringComparison.Ordinal); - Assert.Contains("point.PointKey", overlaySource, StringComparison.Ordinal); + Assert.Contains("TryBuildRowKey(point.DeviceName, point.IecTelegram", overlaySource, StringComparison.Ordinal); Assert.Contains("cache.EvidenceByRow.Remove(key)", overlaySource, StringComparison.Ordinal); Assert.DoesNotContain("ObservableCollection", gridSource, StringComparison.Ordinal); From 1a8d358dc788844990915bf2a561f59bdd431a06 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:09:01 +0700 Subject: [PATCH 069/158] refactor: start P5 by isolating manual FAT compatibility host --- MainWindow.ProductionFatTab.cs | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index ad8a7d25e..ac1b5781b 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -7,14 +7,13 @@ namespace ArIED61850Tester; /// /// Engineering FAT pivot. The normal FAT destination is a native view over the exact -/// Engineering live-row collection; the legacy IoListTestingWindow host remains available -/// only for explicit/manual compatibility workflows. +/// Engineering live-row collection. The legacy IoListTestingWindow host remains available +/// only for explicit/manual compatibility workflows and is never bootstrapped by navigation. /// public partial class MainWindow { private bool _productionFatTabInstalled; private IoListTestingWindow? _productionFatWindow; - private FrameworkElement? _productionFatSurface; private DispatcherTimer? _productionFatInstallRetry; internal bool ProductionFatTabReady => _productionFatTabInstalled && NativeFatTab != null; @@ -59,8 +58,8 @@ private void TryInstallProductionFatTabPivot() _productionFatTabInstalled = true; NativeFatTab.Content = BuildProductionFatPermanentHost(); - // P1A: FAT installation is a view bind only. Do not queue the historical - // Engineering -> IoTest projection/bootstrap from normal FAT navigation. + // P5 normal-entry boundary: installing/navigating FAT is only a canonical view bind. + // No Engineering -> IoTest projection/bootstrap module exists on this path. SynchronizeProductionFatSelectedIed(); NavNativeFatButton.ToolTip = "Factory Acceptance Test · canonical Engineering rows + sparse evidence"; @@ -89,23 +88,6 @@ private FrameworkElement BuildProductionFatPermanentHost( return BuildNativeFatCanonicalWorkspace(effectiveStatus); } - internal void ShowProductionFatBootstrapState(string message, bool isBusy) - { - if (!ProductionFatTabReady || _productionFatWindow is { IsLoaded: true }) - return; - - // Legacy bootstrap diagnostics must not replace the canonical native FAT grid. - // Surface the message in the header while keeping Engineering rows visible. - if (_nativeFatStatusText != null) - { - _nativeFatStatusText.Text = message; - return; - } - - NativeFatTab.Content = BuildProductionFatPermanentHost(message, isBusy); - SynchronizeProductionFatSelectedIed(); - } - private void ProductionFat_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) { if (!ReferenceEquals(e.Source, MainTabs)) @@ -153,7 +135,6 @@ internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkE SaveNativeFatSessionState(); _productionFatWindow = window; - _productionFatSurface = surface; surface.DataContext = window; NativeFatTab.Content = surface; if (_persistentWorkbench != null) @@ -178,7 +159,6 @@ internal void UnmountProductionFatWorkspace(IoListTestingWindow window) window.Closed -= ProductionFatWindow_Closed; _productionFatWindow = null; - _productionFatSurface = null; NativeFatTab.Content = BuildProductionFatPermanentHost(); SynchronizeProductionFatSelectedIed(); } @@ -197,7 +177,6 @@ private void ProductionFat_MainWindowClosed(object? sender, EventArgs e) _productionFatInstallRetry?.Stop(); _productionFatInstallRetry = null; _productionFatWindow = null; - _productionFatSurface = null; if (_nativeFatCanonicalGrid != null) _nativeFatCanonicalGrid.CellEditEnding -= NativeFatCanonicalGrid_CellEditEnding; DisposeNativeFatArmCoordinator(); From 8fc44d8b808a811ec959a63e3146f63be5f37a88 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:09:09 +0700 Subject: [PATCH 070/158] refactor: remove dead automatic Engineering FAT bootstrap module --- ...indow.ProductionFatEngineeringBootstrap.cs | 262 ------------------ 1 file changed, 262 deletions(-) delete mode 100644 MainWindow.ProductionFatEngineeringBootstrap.cs diff --git a/MainWindow.ProductionFatEngineeringBootstrap.cs b/MainWindow.ProductionFatEngineeringBootstrap.cs deleted file mode 100644 index c1785d261..000000000 --- a/MainWindow.ProductionFatEngineeringBootstrap.cs +++ /dev/null @@ -1,262 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Threading; -using ArIED61850Tester.Services.IoTesting; - -namespace ArIED61850Tester; - -/// -/// Makes the embedded production FAT workspace a projection of the Engineering workspace. -/// Opening SCL in Explorer is therefore sufficient: selecting FAT reuses the already-parsed -/// ARIEC SCL/static DataSet authority and the existing Engineering acquisition session. -/// -public partial class MainWindow -{ - private bool _productionFatEngineeringBootstrapInstalled; - private bool _productionFatEngineeringBootstrapBusy; - private CancellationTokenSource? _productionFatEngineeringBootstrapCts; - - [ModuleInitializer] - internal static void RegisterProductionFatEngineeringBootstrap() - { - EventManager.RegisterClassHandler( - typeof(MainWindow), - FrameworkElement.LoadedEvent, - new RoutedEventHandler(ProductionFatEngineeringBootstrap_Loaded), - handledEventsToo: true); - } - - private static void ProductionFatEngineeringBootstrap_Loaded(object sender, RoutedEventArgs e) - { - if (sender is not MainWindow window || window._productionFatEngineeringBootstrapInstalled) - return; - - window._productionFatEngineeringBootstrapInstalled = true; - window.MainTabs.SelectionChanged += window.ProductionFatEngineeringBootstrap_SelectionChanged; - window.PropertyChanged += window.ProductionFatEngineeringBootstrap_PropertyChanged; - window.Closed += window.ProductionFatEngineeringBootstrap_Closed; - } - - private void ProductionFatEngineeringBootstrap_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (!ReferenceEquals(e.Source, MainTabs) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) - return; - QueueProductionFatEngineeringBootstrap(); - } - - private void ProductionFatEngineeringBootstrap_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName != nameof(SelectedDevice) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) - return; - - // A running production FAT session owns its latched IED. The normal embedded-host - // selection bridge handles idle retargeting. Bootstrap is only needed before a FAT - // workspace exists and only while the operator is actually entering FAT. - if (_productionFatWindow == null && _loadedIoFatWindow == null) - QueueProductionFatEngineeringBootstrap(); - } - - private void QueueProductionFatEngineeringBootstrap() - { - // P1A boundary: entering FAT from Engineering is navigation only. The Engineering - // workspace already owns the canonical static DataSet rows and acquisition session; - // never schedule the legacy IoTest projection/bootstrap from this navigation path. - if (!_productionFatEngineeringBootstrapInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) - return; - - // If an older request is still preparing while the operator enters FAT, make it stale - // immediately. Do not start BuildAsync/OpenDescribedSources/ShowIoTestingWorkspace here. - _productionFatEngineeringBootstrapCts?.Cancel(); - - Dispatcher.BeginInvoke( - DispatcherPriority.ContextIdle, - new Action(() => - { - if (MainTabs.SelectedIndex != NativeFatWorkspaceIndex) - return; - - SynchronizeProductionFatSelectedIed(); - })); - } - - private async Task EnsureProductionFatFromEngineeringAsync() - { - if (_productionFatEngineeringBootstrapBusy || - MainTabs.SelectedIndex != NativeFatWorkspaceIndex || - !ProductionFatTabReady) - { - return; - } - - if (_productionFatWindow is { IsLoaded: true } || _loadedIoFatWindow is { IsLoaded: true }) - { - SynchronizeProductionFatSelectedIed(); - return; - } - - var selected = SelectedDevice; - if (selected?.SclWorkspace == null || - selected.SclWorkspace.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) == 0) - { - var message = selected == null - ? "Select an Engineering IED with a static DataSet to prepare FAT." - : $"{selected.Name} has no static DataSet scope in the Engineering SCL model."; - ShowProductionFatBootstrapState(message, isBusy: false); - SetStatus(selected == null - ? "FAT · select an Engineering IED with a static DataSet." - : $"FAT · {selected.Name} has no static DataSet scope in the Engineering SCL model."); - return; - } - - var engineeringDevices = Devices - .Where(device => device.SclWorkspace != null) - .Where(device => device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) > 0) - .Where(device => !string.IsNullOrWhiteSpace(device.SclSourcePath)) - .ToArray(); - if (engineeringDevices.All(device => !ReferenceEquals(device, selected))) - { - var message = $"Engineering source provenance for {selected.Name} is unavailable. Reopen the SCL source to restore FAT authority."; - ShowProductionFatBootstrapState(message, isBusy: false); - SetStatus($"FAT · Engineering source provenance for {selected.Name} is unavailable; use Open SCL to restore the source authority."); - return; - } - - _productionFatEngineeringBootstrapBusy = true; - _productionFatEngineeringBootstrapCts?.Cancel(); - _productionFatEngineeringBootstrapCts?.Dispose(); - _productionFatEngineeringBootstrapCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); - var token = _productionFatEngineeringBootstrapCts.Token; - ShowProductionFatBootstrapState( - $"Reusing {selected.Name} from the Engineering static DataSet authority. No reconnect or SCL re-import is started.", - isBusy: true); - SetStatus($"FAT · preparing {selected.Name} from the Engineering static DataSet…"); - - try - { - var projection = await IoFatEngineeringWorkspaceProjectionService.BuildAsync( - engineeringDevices, - token); - token.ThrowIfCancellationRequested(); - var canonicalStaticRowCount = projection.Project.SignalCount; - - // Register the exact same ARIEC workspace instances already owned by Explorer. - // Production FAT preparation can therefore prove shared SCL authority without - // reparsing XML or starting a second model/acquisition stack. - _ioFatSclProjectImportService.AdoptEngineeringRuntimeWorkspaces(projection.RuntimeWorkspaces); - - // Projection already SHA-256-described the canonical Engineering SCL source set. - // Carry those exact identities through bootstrap/persistence instead of hashing - // the same files again. Staging still verifies every copied byte against SHA-256. - var launch = await IoTestWorkspaceBootstrapService.OpenDescribedSourcesAsync( - projection.Project, - projection.DescribedSources, - IoTestingProjectsRoot(), - IoTestingEvidenceRoot(), - CreateIoTestSession, - token); - token.ThrowIfCancellationRequested(); - - SynchronizeImportedSclFatWithEngineering(launch.Project); - - // Canonical Engineering -> FAT rule: historical scl-manual-* rows are evidence - // input only. Snapshot restore may materialize them for legacy workflows, and - // shared-selection synchronization may encounter them, but the automatic static - // DataSet FAT surface must never expose a second row authority. Migrate evidence - // only when one legacy row maps uniquely to one static member, then physically - // remove every manual row before the production grid/session is exposed. - var migration = IoFatCanonicalEvidenceMigrationService - .MigrateAndRemoveLegacyManualRows(launch.Project); - - // Keep the former selection-only cleanup as an idempotence assertion, not as the - // duplicate fix. Canonical migration above must already have physically removed - // every manual row; if this changes anything, a second authority escaped. - var retiredManualRows = launch.Project.Ieds.Sum( - IoFatEngineeringSelectionBridge.RetireManualWorkspaceRowsForStaticDataSetMode); - if (retiredManualRows != 0) - { - throw new InvalidDataException( - $"Canonical Engineering FAT left {retiredManualRows} manual selection overlay(s) after physical migration."); - } - - if (launch.Project.Ieds - .SelectMany(ied => ied.TestPoints) - .Any(IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow)) - { - throw new InvalidDataException( - "Canonical Engineering FAT still contains a legacy manual SCL row after migration."); - } - - if (launch.Project.SignalCount != canonicalStaticRowCount) - { - throw new InvalidDataException( - $"Canonical Engineering FAT expected {canonicalStaticRowCount} static DataSet row(s), but {launch.Project.SignalCount} row(s) remain after legacy migration."); - } - - if (migration.RemovedManualRows > 0) - { - AddLog( - "INFO", - "FAT", - $"Canonical Engineering FAT removed {migration.RemovedManualRows} legacy manual row(s); migrated evidence for {migration.MigratedEvidenceRows} uniquely mapped row(s). Active rows remain the static DataSet authority only."); - } - - if (migration.AmbiguousEvidenceRows > 0) - { - AddLog( - "WARN", - "FAT", - $"{migration.AmbiguousEvidenceRows} legacy manual evidence row(s) matched multiple static DataSet memberships. ARSAS kept the canonical rows and did not guess an evidence owner; the persisted snapshot remains audit history."); - } - - RegisterSharedSclSourcePaths(launch.Project, launch.Project.Ieds, projection.SourceInputs); - foreach (var ied in launch.Project.Ieds) - { - var device = ResolveIoTestDevice(ied.LiveDeviceId) - ?? ResolveIoTestDevice(ied.IpAddress) - ?? ResolveIoTestDevice(ied.IedName); - if (device is not null) - PreserveSharedStaticDataSetAuthority(device); - } - - launch.Workspace.ScheduleSave(); - await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0); - SynchronizeProductionFatSelectedIed(); - SetStatus($"FAT ready · {selected.Name} · {canonicalStaticRowCount} canonical Engineering row(s) · no SCL re-import."); - } - catch (OperationCanceledException) - { - // Fast navigation/close is normal. No modal interruption is appropriate here. - if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex && _productionFatWindow == null) - { - ShowProductionFatBootstrapState( - "FAT preparation was cancelled. Select the FAT tab again to retry from the current Engineering authority.", - isBusy: false); - } - } - catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) - { - AddLog("WARN", "FAT", $"Automatic Engineering FAT bootstrap unavailable: {ex.Message}"); - ShowProductionFatBootstrapState( - $"FAT could not reuse the current Engineering static DataSet: {ex.Message}", - isBusy: false); - SetStatus($"FAT · could not reuse the Engineering static DataSet automatically: {ex.Message}"); - } - finally - { - _productionFatEngineeringBootstrapBusy = false; - } - } - - private void ProductionFatEngineeringBootstrap_Closed(object? sender, EventArgs e) - { - MainTabs.SelectionChanged -= ProductionFatEngineeringBootstrap_SelectionChanged; - PropertyChanged -= ProductionFatEngineeringBootstrap_PropertyChanged; - Closed -= ProductionFatEngineeringBootstrap_Closed; - _productionFatEngineeringBootstrapCts?.Cancel(); - _productionFatEngineeringBootstrapCts?.Dispose(); - _productionFatEngineeringBootstrapCts = null; - } -} From e875d5e88770f1270a48519c1ba7e037af9058c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:09:38 +0700 Subject: [PATCH 071/158] test: lock P5 removal of automatic Engineering FAT bootstrap --- ...ductionFatEngineeringTabRegressionTests.cs | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs index 868084656..249579ca4 100644 --- a/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs @@ -5,6 +5,8 @@ public sealed class ProductionFatEngineeringTabRegressionTests [Fact] public void EngineeringProjection_ReusesParsedSclWorkspaceWithoutOpeningXmlAgain() { + // This service remains available to explicit/manual compatibility workflows. + // P5 removes it only from normal native FAT navigation/runtime ownership. var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs")); Assert.Contains("device.SclWorkspace", source, StringComparison.Ordinal); @@ -17,27 +19,40 @@ public void EngineeringProjection_ReusesParsedSclWorkspaceWithoutOpeningXmlAgain } [Fact] - public void ProductionFatTab_EntryReusesExistingHostWithoutLegacyProjectionBootstrap() + public void ProductionFatTab_NormalEntryHasNoLegacyProjectionBootstrapModule() { - var source = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatEngineeringBootstrap.cs")); - var queueStart = source.IndexOf("private void QueueProductionFatEngineeringBootstrap()", StringComparison.Ordinal); - var legacyStart = source.IndexOf("private async Task EnsureProductionFatFromEngineeringAsync()", queueStart, StringComparison.Ordinal); - - Assert.True(queueStart >= 0, "P1A requires one explicit Engineering -> FAT navigation gateway."); - Assert.True(legacyStart > queueStart, "Legacy bootstrap may remain isolated, but it must not own FAT navigation."); - - var queue = source[queueStart..legacyStart]; - Assert.Contains("_productionFatEngineeringBootstrapCts?.Cancel();", queue, StringComparison.Ordinal); - Assert.Contains("SynchronizeProductionFatSelectedIed();", queue, StringComparison.Ordinal); - Assert.DoesNotContain("EnsureProductionFatFromEngineeringAsync", queue, StringComparison.Ordinal); - Assert.DoesNotContain("IoFatEngineeringWorkspaceProjectionService.BuildAsync", queue, StringComparison.Ordinal); - Assert.DoesNotContain("OpenDescribedSourcesAsync", queue, StringComparison.Ordinal); - Assert.DoesNotContain("ShowIoTestingWorkspaceAsync", queue, StringComparison.Ordinal); - Assert.DoesNotContain("OpenSclFatTesting_Click", queue, StringComparison.Ordinal); + var source = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + var repoRoot = FindRepoRoot(); + + Assert.False( + File.Exists(Path.Combine(repoRoot, "MainWindow.ProductionFatEngineeringBootstrap.cs")), + "P5 removes the automatic Engineering -> legacy IoTest bootstrap module from normal FAT navigation."); + + Assert.Contains("NativeFatTab.Content = BuildProductionFatPermanentHost();", source, StringComparison.Ordinal); + Assert.Contains("SynchronizeProductionFatSelectedIed();", source, StringComparison.Ordinal); + Assert.Contains("BindNativeFatCanonicalRows();", source, StringComparison.Ordinal); + + foreach (var forbidden in new[] + { + "QueueProductionFatEngineeringBootstrap", + "EnsureProductionFatFromEngineeringAsync", + "IoFatEngineeringWorkspaceProjectionService", + "IoTestWorkspaceBootstrapService", + "OpenDescribedSourcesAsync", + "ShowIoTestingWorkspaceAsync", + "ShowProductionFatBootstrapState" + }) + { + Assert.DoesNotContain(forbidden, source, StringComparison.Ordinal); + } + + // Explicit/manual compatibility remains a deliberate, operator-invoked boundary. + Assert.Contains("MountProductionFatWorkspace", source, StringComparison.Ordinal); + Assert.Contains("FAT compatibility workspace", source, StringComparison.Ordinal); } [Fact] - public void ProductionFatTab_RegistersExactEngineeringRuntimeWorkspacesForSharedAcquisition() + public void ExplicitCompatibilityProjection_CanStillAdoptExactEngineeringRuntimeWorkspaces() { var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatSclProjectImportService.cs")); @@ -94,14 +109,15 @@ public void ExistingWorkspaceSelectionSideEffects_RemainProtectedWhileAddingFat( } [Fact] - public void ProductionFatSafetyBoundary_KeepsEngineeringAuthorityAndStrictPreflightCoverage() + public void ExplicitLegacyMigrationSafety_RemainsAvailableWithoutOwningNormalFatRuntime() { - var bootstrapSource = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatEngineeringBootstrap.cs")); + var migrationSource = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs")); var fieldRegressionSource = File.ReadAllText(FindRepoFile("tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs")); var preflightSource = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestSessionPreflight.cs")); var preflightTests = File.ReadAllText(FindRepoFile("tests/ARSAS.Tests/IoTestSessionPreflightTests.cs")); - Assert.Contains("RetireManualWorkspaceRowsForStaticDataSetMode", bootstrapSource, StringComparison.Ordinal); + Assert.Contains("MigrateAndRemoveLegacyManualRows", migrationSource, StringComparison.Ordinal); + Assert.Contains("IsLegacyManualWorkspaceRow", migrationSource, StringComparison.Ordinal); Assert.Contains("AutomaticStaticDataSetScope_RetiresManualAliasBeforeSessionPreflight", fieldRegressionSource, StringComparison.Ordinal); Assert.Contains("IoTestSessionPreflight.Validate", fieldRegressionSource, StringComparison.Ordinal); Assert.Contains("RetireRedundantManualWorkspaceRows", preflightSource, StringComparison.Ordinal); From 143fca9e5d66341438f9e0aab11b332d0569c041 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:10:13 +0700 Subject: [PATCH 072/158] refactor: remove legacy bootstrap state parameters from native FAT host --- MainWindow.ProductionFatTab.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index ac1b5781b..222f9611c 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -77,16 +77,8 @@ private void ProductionFatInstallRetry_Tick(object? sender, EventArgs e) TryInstallProductionFatTabPivot(); } - private FrameworkElement BuildProductionFatPermanentHost( - string? statusText = null, - bool isBusy = false) - { - var effectiveStatus = isBusy && !string.IsNullOrWhiteSpace(statusText) - ? $"{statusText}" - : statusText; - - return BuildNativeFatCanonicalWorkspace(effectiveStatus); - } + private FrameworkElement BuildProductionFatPermanentHost() + => BuildNativeFatCanonicalWorkspace(); private void ProductionFat_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) { From 8f1bf200c5ed9124241d69f3405faebdafc05fc5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:12:03 +0700 Subject: [PATCH 073/158] fix: require explicit control feedback reference for stable confirmation --- MainWindow.ControlDiagnostics.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/MainWindow.ControlDiagnostics.cs b/MainWindow.ControlDiagnostics.cs index 14b2e1cf4..91299a1ca 100644 --- a/MainWindow.ControlDiagnostics.cs +++ b/MainWindow.ControlDiagnostics.cs @@ -475,10 +475,13 @@ private bool HasFreshStableEvidence(ActivePositionCommand state) private static string ResolveControlFeedbackKey(SignalDefinition signal) { - var reference = string.IsNullOrWhiteSpace(signal.ControlStatusReference) - ? $"{signal.ObjectReference}.stVal" - : signal.ControlStatusReference; - return NormalizeReference(reference); + // P4E fail-closed contract: stable command confirmation is allowed only when live + // control discovery supplied an explicit status reference. Never infer .stVal from + // ObjectReference, SignalName, row order, or any runtime identifier. + if (string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + return string.Empty; + + return NormalizeReference(signal.ControlStatusReference); } private async Task ExpirePositionCommandAsync(ActivePositionCommand state) From e8e92f1122df6ac97e31822bf7172b284218419d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:12:22 +0700 Subject: [PATCH 074/158] test: lock explicit-only command feedback identity --- ...ativeFatP4ECommandFeedbackCorrelationTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs b/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs index d8e57bfd9..e75ada54a 100644 --- a/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs @@ -99,6 +99,22 @@ public void SourceContract_HasNoDisplayIndexOrRuntimeIdentityFallback() Assert.DoesNotContain("ObjectReference", source, StringComparison.Ordinal); } + [Fact] + public void GlobalStableCommandConfirmation_AlsoRequiresExplicitControlStatusReference() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.ControlDiagnostics.cs")); + var start = source.IndexOf("private static string ResolveControlFeedbackKey", StringComparison.Ordinal); + var end = source.IndexOf("private async Task ExpirePositionCommandAsync", start, StringComparison.Ordinal); + + Assert.True(start >= 0 && end > start); + var resolver = source[start..end]; + Assert.Contains("string.IsNullOrWhiteSpace(signal.ControlStatusReference)", resolver, StringComparison.Ordinal); + Assert.Contains("return string.Empty;", resolver, StringComparison.Ordinal); + Assert.Contains("NormalizeReference(signal.ControlStatusReference)", resolver, StringComparison.Ordinal); + Assert.DoesNotContain("signal.ObjectReference", resolver, StringComparison.Ordinal); + Assert.DoesNotContain(".stVal", resolver, StringComparison.Ordinal); + } + private static Iec61850MonitorDevice Device(string name) => new() { From 83d49768815a2f4f1d0c48149944c14d44f7666c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:13:55 +0700 Subject: [PATCH 075/158] test: lock P5 native FAT runtime against legacy bridge reentry --- .../NativeFatP5LegacyBridgeRemovalTests.cs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs diff --git a/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs b/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs new file mode 100644 index 000000000..8870da690 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs @@ -0,0 +1,109 @@ +namespace ARSAS.Tests; + +public sealed class NativeFatP5LegacyBridgeRemovalTests +{ + [Fact] + public void P5_NormalNativeFatRuntimeHasNoProjectionBootstrapReconnectOrSecondAcquisitionOwner() + { + var grid = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var arm = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatArmCoordinator.cs")); + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var tab = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + Assert.Contains("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", grid, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay", arm, StringComparison.Ordinal); + Assert.Contains("NativeFatPrintPreviewSnapshot.Capture", preview, StringComparison.Ordinal); + Assert.Contains("BuildNativeFatCanonicalWorkspace", tab, StringComparison.Ordinal); + + var normalRuntimeSources = new[] { grid, arm, preview }; + foreach (var source in normalRuntimeSources) + { + foreach (var forbidden in new[] + { + "IoListTestingWindow", + "IoFatEngineeringWorkspaceProjectionService", + "IoTestWorkspaceBootstrapService", + "OpenDescribedSourcesAsync", + "PrepareIoTestIedForFatAsync", + "ConnectAndDiscoverAsync", + "ConnectUsingCachedModelAsync", + "StartMonitoringAsync", + "ShowIoTestingWorkspaceAsync", + "FatSclWorkspaceImportService" + }) + { + Assert.DoesNotContain(forbidden, source, StringComparison.Ordinal); + } + } + + foreach (var forbidden in new[] + { + "QueueProductionFatEngineeringBootstrap", + "EnsureProductionFatFromEngineeringAsync", + "IoFatEngineeringWorkspaceProjectionService", + "IoTestWorkspaceBootstrapService", + "OpenDescribedSourcesAsync", + "ShowIoTestingWorkspaceAsync" + }) + { + Assert.DoesNotContain(forbidden, tab, StringComparison.Ordinal); + } + } + + [Fact] + public void P5_AutomaticEngineeringBootstrapModuleIsPhysicallyRemoved() + { + var root = FindRepoRoot(); + Assert.False( + File.Exists(Path.Combine(root, "MainWindow.ProductionFatEngineeringBootstrap.cs")), + "Normal native FAT must not regain the automatic Engineering -> legacy IoTest bootstrap module."); + } + + [Fact] + public void P5_ExplicitManualCompatibilityRemainsAnIsolatedOperatorBoundary() + { + var tab = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + Assert.Contains("MountProductionFatWorkspace", tab, StringComparison.Ordinal); + Assert.Contains("UnmountProductionFatWorkspace", tab, StringComparison.Ordinal); + Assert.Contains("FAT compatibility workspace", tab, StringComparison.Ordinal); + Assert.Contains("if (_productionFatWindow is { IsLoaded: true })", tab, StringComparison.Ordinal); + Assert.Contains("BindNativeFatCanonicalRows();", tab, StringComparison.Ordinal); + } + + [Fact] + public void P5_LegacyEvidenceHydrationIsPassiveDataMigrationNotWorkspaceBootstrap() + { + var hydration = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatEvidenceHydrationService.cs")); + + Assert.Contains("TryHydrateLegacySnapshotAsync", hydration, StringComparison.Ordinal); + Assert.Contains("without opening the legacy workspace", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("IoListTestingWindow", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("IoTestWorkspaceBootstrapService", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("OpenDescribedSourcesAsync", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("ShowIoTestingWorkspaceAsync", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("StartMonitoringAsync", hydration, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From 3de255e7c20b4b5e36dfc438c28718263126a745 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:26:02 +0700 Subject: [PATCH 076/158] FAT P5: remove dead Engineering projection bridge --- MainWindow.ProductionFatNoFlicker.cs | 28 -- .../IoFatCanonicalEvidenceMigrationService.cs | 138 -------- ...atEngineeringWorkspaceProjectionService.cs | 303 ------------------ .../IoTesting/IoTestSignalSelectionService.cs | 11 +- ...nonicalEvidenceMigrationRegressionTests.cs | 203 ------------ ...ngAuthorityNormalizationRegressionTests.cs | 104 ------ 6 files changed, 3 insertions(+), 784 deletions(-) delete mode 100644 MainWindow.ProductionFatNoFlicker.cs delete mode 100644 Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs delete mode 100644 Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs delete mode 100644 tests/ARSAS.Tests/IoFatCanonicalEvidenceMigrationRegressionTests.cs delete mode 100644 tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs diff --git a/MainWindow.ProductionFatNoFlicker.cs b/MainWindow.ProductionFatNoFlicker.cs deleted file mode 100644 index f346e68f2..000000000 --- a/MainWindow.ProductionFatNoFlicker.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Windows; - -namespace ArIED61850Tester; - -/// -/// P0 field fix for the embedded Engineering FAT transition. -/// -/// The legacy production FAT launcher owns a historical MainWindow.Hide() / child Show() -/// hand-off. That is still required by standalone compatibility flows, but it is wrong for -/// the automatic Engineering -> embedded FAT path because the FAT tab is already visible -/// and the child Window exists only as a hidden controller/lifecycle owner. Suppress that -/// single hide while the automatic embedded bootstrap is in flight so the user never sees -/// the desktop/black frame between two WPF windows. -/// -public partial class MainWindow -{ - public new void Hide() - { - if (ShouldKeepEngineeringVisibleDuringProductionFatBootstrap()) - return; - - base.Hide(); - } - - private bool ShouldKeepEngineeringVisibleDuringProductionFatBootstrap() - => _productionFatEngineeringBootstrapBusy && - ProductionFatTabReady; -} diff --git a/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs b/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs deleted file mode 100644 index 4a952f217..000000000 --- a/Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs +++ /dev/null @@ -1,138 +0,0 @@ -using ArIED61850Tester.Models.IoTesting; - -namespace ArIED61850Tester.Services.IoTesting; - -/// -/// Canonicalizes the automatic Engineering -> FAT static DataSet workspace. -/// Historical scl-manual-* rows are evidence/provenance input only: they must never -/// survive as a second active FAT row beside the authoritative static DataSet member. -/// -public static class IoFatCanonicalEvidenceMigrationService -{ - public sealed record Result( - int RemovedManualRows, - int MigratedEvidenceRows, - int AmbiguousEvidenceRows); - - public static Result MigrateAndRemoveLegacyManualRows(IoTestProject project) - { - ArgumentNullException.ThrowIfNull(project); - - var removed = 0; - var migrated = 0; - var ambiguous = 0; - - foreach (var ied in project.Ieds) - { - // The Engineering projection carries a provenance-specific immutable binding - // status. IoTestSignalSelectionService recognizes it as the same static DataSet - // authority as direct SCL imports. This check therefore covers the real field - // case: canonical Engineering rows plus restored scl-manual-* snapshot history. - if (!ied.TestPoints.Any(IoTestSignalSelectionService.IsSclDataSetAuthority)) - continue; - - var manualRows = ied.TestPoints - .Where(IsLegacyManualWorkspaceRow) - .ToArray(); - - foreach (var manual in manualRows) - { - var runtimeReference = FirstNonEmpty( - manual.LiveSignalReference, - manual.ObjectReference, - manual.EventLogSearchReference, - manual.SourceIecReference, - manual.ReportDisplayReference); - - var canonicalMatches = IoFatEngineeringSelectionBridge - .FindStaticDataSetRuntimeCoverage( - ied, - runtimeReference, - manual.FunctionalConstraint) - .ToArray(); - - if (canonicalMatches.Length == 1) - { - if (MigrateEvidenceOnly(manual, canonicalMatches[0])) - migrated++; - } - else if (canonicalMatches.Length > 1 && HasEvidence(manual)) - { - // A legacy scalar can cover more than one distinct static membership. - // Never guess which canonical row owns old evidence. Keep the source - // snapshot as audit history and require fresh evidence for those rows. - ambiguous++; - } - - // Removal is intentional even when evidence cannot be mapped uniquely. - // The automatic Engineering FAT path is a static DataSet projection; a - // historical manual alias is not a second IEC/SCL row authority. - if (ied.TestPoints.Remove(manual)) - removed++; - } - } - - return new Result(removed, migrated, ambiguous); - } - - public static bool IsLegacyManualWorkspaceRow(IoTestPointPlan point) - { - ArgumentNullException.ThrowIfNull(point); - return point.TestPointId.StartsWith("scl-manual-", StringComparison.OrdinalIgnoreCase) || - IoTestSignalSelectionService.IsSclWorkspaceAuthority(point); - } - - private static bool MigrateEvidenceOnly(IoTestPointPlan source, IoTestPointPlan target) - { - var changed = false; - - if (target.Runtime.Value1Evidence is null && source.Runtime.Value1Evidence is not null) - { - target.Runtime.Value1Evidence = source.Runtime.Value1Evidence; - changed = true; - } - - if (target.Runtime.Value2Evidence is null && source.Runtime.Value2Evidence is not null) - { - target.Runtime.Value2Evidence = source.Runtime.Value2Evidence; - changed = true; - } - - if (target.Runtime.OnEvidence is null && source.Runtime.OnEvidence is not null) - { - target.Runtime.OnEvidence = source.Runtime.OnEvidence; - changed = true; - } - - if (target.Runtime.OffEvidence is null && source.Runtime.OffEvidence is not null) - { - target.Runtime.OffEvidence = source.Runtime.OffEvidence; - changed = true; - } - - if (!target.Runtime.IsComplete && source.Runtime.IsComplete) - { - target.Runtime.State = source.Runtime.State; - target.Runtime.StatusReason = source.Runtime.StatusReason; - changed = true; - } - - if (source.Runtime.Attempt > target.Runtime.Attempt) - { - target.Runtime.Attempt = source.Runtime.Attempt; - changed = true; - } - - return changed; - } - - private static bool HasEvidence(IoTestPointPlan point) - => point.Runtime.Value1Evidence is not null || - point.Runtime.Value2Evidence is not null || - point.Runtime.OnEvidence is not null || - point.Runtime.OffEvidence is not null || - point.Runtime.IsComplete; - - private static string FirstNonEmpty(params string?[] values) - => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; -} diff --git a/Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs b/Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs deleted file mode 100644 index 81eb06dd3..000000000 --- a/Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs +++ /dev/null @@ -1,303 +0,0 @@ -using AR.Iec61850.Scl.Workspace; -using ArIED61850Tester.Models; -using ArIED61850Tester.Models.IoTesting; - -namespace ArIED61850Tester.Services.IoTesting; - -public sealed record IoFatEngineeringWorkspaceProjection( - IoTestProject Project, - IReadOnlyList SourceInputs, - IReadOnlyList DescribedSources, - IReadOnlyList RuntimeWorkspaces); - -/// -/// Builds the production FAT project directly from the already-parsed Engineering SCL -/// workspaces. This is deliberately not an SCL importer: it never opens/parses XML and it -/// never creates a second IEC 61850 model. Engineering remains the static DataSet/live-value -/// authority; FAT adds only its production evidence/session lifecycle on top. -/// -public static class IoFatEngineeringWorkspaceProjectionService -{ - public static async Task BuildAsync( - IReadOnlyCollection devices, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(devices); - - var usable = devices - .Where(device => device.SclWorkspace != null) - .Where(device => device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) > 0) - .Where(device => !string.IsNullOrWhiteSpace(device.SclSourcePath)) - .GroupBy(device => device.DeviceId, StringComparer.OrdinalIgnoreCase) - .Select(group => group.Last()) - .ToArray(); - if (usable.Length == 0) - { - throw new InvalidDataException( - "No Engineering IED has an already-parsed SCL workspace with static DataSet members and source provenance."); - } - - // Describe every candidate source once so conflicting Engineering authorities are - // detected before canonicalization. The resulting canonical source set is then used - // for the FAT projection/staging path so duplicate Explorer entries do not multiply - // static DataSet rows or repeat downstream workspace work. - var candidateSourceInputs = usable - .Select(device => Path.GetFullPath(device.SclSourcePath)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(path => new IoFatSourceInput(path, IoFatSourceKinds.Scl)) - .ToArray(); - var described = await IoFatSourceWorkspaceService.DescribeAsync(candidateSourceInputs, cancellationToken) - .ConfigureAwait(false); - var descriptorByPath = described.ToDictionary( - item => Path.GetFullPath(item.OriginalPath), - item => item.Source, - StringComparer.OrdinalIgnoreCase); - - foreach (var device in usable) - { - cancellationToken.ThrowIfCancellationRequested(); - var path = Path.GetFullPath(device.SclSourcePath); - if (!descriptorByPath.TryGetValue(path, out var descriptor)) - throw new InvalidDataException($"Engineering SCL provenance is unavailable for '{device.Name}'."); - if (!string.IsNullOrWhiteSpace(device.SclSourceSha256) && - !descriptor.Sha256.Equals(device.SclSourceSha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidDataException( - $"The Engineering SCL source for '{device.Name}' changed on disk after it was parsed. FAT will not bind a stale in-memory model to different source bytes."); - } - } - - var canonicalDevices = CanonicalizeEngineeringDevices(usable, descriptorByPath); - var sourceInputs = canonicalDevices - .Select(device => Path.GetFullPath(device.SclSourcePath)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(path => new IoFatSourceInput(path, IoFatSourceKinds.Scl)) - .ToArray(); - var canonicalDescribedSources = sourceInputs - .Select(input => - { - var path = Path.GetFullPath(input.FilePath); - return new IoFatDescribedSource(descriptorByPath[path], path); - }) - .ToArray(); - - var workspaceSources = canonicalDevices - .Select(device => - { - var descriptor = descriptorByPath[Path.GetFullPath(device.SclSourcePath)]; - return new FatSclWorkspaceSource( - descriptor.FileName, - descriptor.Sha256, - device.SclWorkspace!); - }) - .ToArray(); - var verification = FatSclWorkspaceImportService.Import(workspaceSources).Project; - - var deviceByWorkspace = canonicalDevices - .ToDictionary( - device => WorkspaceIdentity(device.SclWorkspace!), - device => device, - StringComparer.OrdinalIgnoreCase); - var descriptorByWorkspace = canonicalDevices - .ToDictionary( - device => WorkspaceIdentity(device.SclWorkspace!), - device => descriptorByPath[Path.GetFullPath(device.SclSourcePath)], - StringComparer.OrdinalIgnoreCase); - - var plans = new List(); - foreach (var workspaceGroup in workspaceSources - .GroupBy(source => WorkspaceIdentity(source.Workspace), StringComparer.OrdinalIgnoreCase) - .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) - { - var workspace = workspaceGroup.First().Workspace; - var key = WorkspaceIdentity(workspace); - var device = deviceByWorkspace[key]; - var descriptor = descriptorByWorkspace[key]; - var signals = verification.Signals - .Where(signal => WorkspaceIdentity(signal.IedName, signal.AccessPointName) - .Equals(key, StringComparison.OrdinalIgnoreCase)) - .OrderBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase) - .ThenBy(signal => signal.DataSetMemberIndex) - .ToArray(); - - var endpoint = !string.IsNullOrWhiteSpace(device.IpAddress) - ? device.IpAddress - : workspace.PreferredEndpoint?.HasUsableAddress == true - ? workspace.PreferredEndpoint.IpAddress - : string.Empty; - var plan = new IoTestIedPlan - { - IedName = workspace.IedName, - IpAddress = endpoint, - IedRole = FirstNonEmpty(workspace.IedType, workspace.Manufacturer), - TestPoints = signals.Select(signal => ToPointPlan(signal, descriptor, workspace, endpoint)).ToList() - }; - plan.ApplyLiveDeviceBinding( - device.DeviceId, - device.IsMonitoring ? "Engineering acquisition active" : device.IsConnected ? "Engineering association ready" : "Engineering SCL model ready", - device.IsConnected, - device.IsMonitoring); - plans.Add(plan); - } - - var sourceDescriptors = canonicalDevices - .Select(device => descriptorByPath[Path.GetFullPath(device.SclSourcePath)]) - .GroupBy(source => source.SourceId, StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(source => source.SourceId, StringComparer.Ordinal) - .ToArray(); - var sourceFingerprint = IoFatSourceIdentity.ComputeSetFingerprint(sourceDescriptors); - var project = new IoTestProject - { - ProjectId = "FAT-SCL-" + sourceFingerprint[..16], - SchemaVersion = "ARSAS-FAT-SCL-1.0", - ProjectName = sourceDescriptors.Length == 1 - ? Path.GetFileNameWithoutExtension(sourceDescriptors[0].FileName) + " FAT" - : $"IEC 61850 SCL FAT ({sourceDescriptors.Length} sources)", - DocumentControl = new IoFatDocumentControl - { - DocumentTitle = "IEC 61850 FAT", - SourceDocumentName = string.Join("; ", sourceDescriptors.Select(source => source.FileName)) - }, - Ieds = plans - }; - project.SetSources(sourceDescriptors, sourceFingerprint); - project.InitializeRuntimeNotifications(); - - var staticMemberCount = canonicalDevices.Sum(device => - device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count)); - if (project.SignalCount != verification.Signals.Count || project.SignalCount != staticMemberCount) - { - throw new InvalidDataException( - $"Engineering FAT projection produced {project.SignalCount} row(s), but the authoritative static DataSet scope contains {staticMemberCount}. FAT refuses a partial projection."); - } - - return new IoFatEngineeringWorkspaceProjection( - project, - sourceInputs, - canonicalDescribedSources, - canonicalDevices.Select(device => device.SclWorkspace!).ToArray()); - } - - private static IReadOnlyList CanonicalizeEngineeringDevices( - IReadOnlyCollection devices, - IReadOnlyDictionary descriptorByPath) - { - var canonical = new List(); - foreach (var identityGroup in devices - .GroupBy(device => WorkspaceIdentity(device.SclWorkspace!), StringComparer.OrdinalIgnoreCase) - .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) - { - var candidates = identityGroup - .Select(device => - { - var path = Path.GetFullPath(device.SclSourcePath); - return new - { - Device = device, - Path = path, - Descriptor = descriptorByPath[path] - }; - }) - .ToArray(); - - var distinctHashes = candidates - .Select(candidate => candidate.Descriptor.Sha256) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (distinctHashes.Length > 1) - { - throw new InvalidDataException( - $"Conflicting Engineering SCL sources define the same IED/AccessPoint '{identityGroup.Key}'. " + - "FAT will not silently merge competing static DataSet authorities."); - } - - var distinctEndpoints = candidates - .Select(candidate => candidate.Device.IpAddress?.Trim() ?? string.Empty) - .Where(endpoint => !string.IsNullOrWhiteSpace(endpoint)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (distinctEndpoints.Length > 1) - { - throw new InvalidDataException( - $"Engineering exposes IED/AccessPoint '{identityGroup.Key}' through multiple endpoints ({string.Join(", ", distinctEndpoints)}). " + - "FAT will not guess which physical IED owns the evidence session."); - } - - // Exact same-content duplicates are already harmless according to the lower SCL - // importer contract. Collapse them here as well so projection counts, runtime - // workspaces and staged source files all share one canonical authority. Prefer the - // currently monitoring/connected Engineering device so FAT remains attached to the - // live session that the operator is already using. - var selected = candidates - .OrderByDescending(candidate => candidate.Device.IsMonitoring) - .ThenByDescending(candidate => candidate.Device.IsConnected) - .ThenBy(candidate => candidate.Descriptor.FileName, StringComparer.OrdinalIgnoreCase) - .ThenBy(candidate => candidate.Path, StringComparer.OrdinalIgnoreCase) - .ThenBy(candidate => candidate.Device.DeviceId, StringComparer.OrdinalIgnoreCase) - .First(); - canonical.Add(selected.Device); - } - - return canonical; - } - - private static IoTestPointPlan ToPointPlan( - FatVerificationSignal signal, - IoFatSourceDescriptor source, - SclIedWorkspace workspace, - string endpoint) - { - var discrete = signal.SignalKind == FatSignalKind.Discrete; - return new IoTestPointPlan - { - TestPointId = $"scl-{source.SourceId}-{signal.SignalId}", - IedName = signal.IedName, - IpAddress = endpoint, - SignalName = signal.SignalName, - ObjectReference = FirstNonEmpty(signal.RuntimeReference, signal.StaticMemberReference), - FunctionalConstraint = signal.FunctionalConstraint, - ExpectedOnText = discrete ? "TRUE" : "Value 1", - ExpectedOffText = discrete ? "FALSE" : "Value 2", - ExpectedOnRaw = 1, - ExpectedOffRaw = 0, - DataType = signal.DataType, - SignalAddress = source.SourceId, - DataSetName = signal.DataSetReference, - SourceIecReference = signal.StaticMemberReference, - ReportDisplayReference = signal.StaticMemberReference, - EventLogSearchReference = signal.StaticMemberReference, - EvidenceExpected = signal.CaptureMode == FatCaptureMode.AutomaticTransition - ? "Automatic Value 1 / Value 2 transition capture" - : "Operator Value 1 / Value 2 snapshot capture", - SourceSheet = source.FileName, - SourceRow = signal.DataSetMemberIndex + 1, - SignalKind = signal.SignalKind, - CaptureMode = signal.CaptureMode, - TestEnabled = true, - ImportReady = true, - BindingStatus = "ENGINEERING_SCL_DATASET_AUTHORITY", - BindingEvidence = string.Join(" • ", new[] - { - "shared Engineering ARIEC static DataSet authority", - $"sourceId={source.SourceId}", - $"sourceSha256={source.Sha256}", - $"workspace={workspace.WorkspaceKey}", - $"dataset={signal.DataSetReference}", - $"memberIndex={signal.DataSetMemberIndex}", - $"static={signal.StaticMemberReference}", - $"kind={signal.SignalKind}", - $"capture={signal.CaptureMode}" - }) - }; - } - - private static string WorkspaceIdentity(SclIedWorkspace workspace) - => WorkspaceIdentity(workspace.IedName, workspace.AccessPointName); - - private static string WorkspaceIdentity(string? iedName, string? accessPointName) - => $"{(iedName ?? string.Empty).Trim()}|{(accessPointName ?? string.Empty).Trim()}"; - - private static string FirstNonEmpty(params string?[] values) - => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; -} diff --git a/Services/IoTesting/IoTestSignalSelectionService.cs b/Services/IoTesting/IoTestSignalSelectionService.cs index 2f905c0bd..22a47c7b0 100644 --- a/Services/IoTesting/IoTestSignalSelectionService.cs +++ b/Services/IoTesting/IoTestSignalSelectionService.cs @@ -31,7 +31,6 @@ public sealed record IoTestSignalSelectionResult( public sealed class IoTestSignalSelectionService { internal const string SclDataSetAuthorityBindingStatus = "SCL_DATASET_AUTHORITY"; - internal const string EngineeringSclDataSetAuthorityBindingStatus = "ENGINEERING_SCL_DATASET_AUTHORITY"; internal const string SclWorkspaceAuthorityBindingStatus = "SCL_WORKSPACE_AUTHORITY"; private const int SclStaticMembershipIdentityBonus = 1000; @@ -246,13 +245,9 @@ private static bool TryResolveAlreadyLiveExactScope( internal static bool IsSclDataSetAuthority(IoTestPointPlan point) => string.Equals( - point.BindingStatus, - SclDataSetAuthorityBindingStatus, - StringComparison.OrdinalIgnoreCase) || - string.Equals( - point.BindingStatus, - EngineeringSclDataSetAuthorityBindingStatus, - StringComparison.OrdinalIgnoreCase); + point.BindingStatus, + SclDataSetAuthorityBindingStatus, + StringComparison.OrdinalIgnoreCase); internal static bool IsSclWorkspaceAuthority(IoTestPointPlan point) => string.Equals( diff --git a/tests/ARSAS.Tests/IoFatCanonicalEvidenceMigrationRegressionTests.cs b/tests/ARSAS.Tests/IoFatCanonicalEvidenceMigrationRegressionTests.cs deleted file mode 100644 index a372721ae..000000000 --- a/tests/ARSAS.Tests/IoFatCanonicalEvidenceMigrationRegressionTests.cs +++ /dev/null @@ -1,203 +0,0 @@ -using ArIED61850Tester.Models.IoTesting; -using ArIED61850Tester.Services.IoTesting; - -namespace ARSAS.Tests; - -public sealed class IoFatCanonicalEvidenceMigrationRegressionTests -{ - [Fact] - public void EngineeringStaticDataSetMigration_RemovesLegacyManualRowAndKeepsUniqueEvidence() - { - const string runtime = "AA1E1F06R4V1T3p1_OperationalValues/RPRE_MMXU1.A.phsA.cVal.mag.f"; - var canonical = StaticPoint("scl-static-1", runtime); - var manual = ManualPoint("scl-manual-7496d038be4fdc18e340", runtime); - Bind(canonical, runtime); - Bind(manual, runtime); - - var value1 = Evidence(FatValueSlot.Value1, "10.1"); - var value2 = Evidence(FatValueSlot.Value2, "12.7"); - manual.Runtime.Value1Evidence = value1; - manual.Runtime.Value2Evidence = value2; - manual.Runtime.State = IoTestPointState.Passed; - manual.Runtime.StatusReason = "legacy completed result"; - manual.Runtime.Attempt = 2; - - var project = Project(canonical, manual); - - var result = IoFatCanonicalEvidenceMigrationService.MigrateAndRemoveLegacyManualRows(project); - - Assert.Equal(1, result.RemovedManualRows); - Assert.Equal(1, result.MigratedEvidenceRows); - Assert.Equal(0, result.AmbiguousEvidenceRows); - Assert.Single(project.Ieds[0].TestPoints); - Assert.Same(canonical, project.Ieds[0].TestPoints[0]); - Assert.DoesNotContain(project.Ieds[0].TestPoints, IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow); - Assert.Same(value1, canonical.Runtime.Value1Evidence); - Assert.Same(value2, canonical.Runtime.Value2Evidence); - Assert.Equal(IoTestPointState.Passed, canonical.Runtime.State); - Assert.Equal(2, canonical.Runtime.Attempt); - } - - [Fact] - public void EngineeringStaticDataSetMigration_AmbiguousLegacyEvidenceNeverCreatesOrChoosesDuplicateAuthority() - { - const string runtime = "AA1E1F06R4LD0/GGIO1.AnIn1.mag.f"; - var canonicalA = StaticPoint("scl-static-a", runtime, "IED/LLN0.dsA"); - var canonicalB = StaticPoint("scl-static-b", runtime, "IED/LLN0.dsB"); - var manual = ManualPoint("scl-manual-aaaaaaaaaaaaaaaaaaaa", runtime); - Bind(canonicalA, runtime); - Bind(canonicalB, runtime); - Bind(manual, runtime); - manual.Runtime.Value1Evidence = Evidence(FatValueSlot.Value1, "3.14"); - - var project = Project(canonicalA, canonicalB, manual); - - var result = IoFatCanonicalEvidenceMigrationService.MigrateAndRemoveLegacyManualRows(project); - - Assert.Equal(1, result.RemovedManualRows); - Assert.Equal(0, result.MigratedEvidenceRows); - Assert.Equal(1, result.AmbiguousEvidenceRows); - Assert.Equal(2, project.SignalCount); - Assert.All(project.Ieds[0].TestPoints, point => Assert.False(IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow(point))); - Assert.Null(canonicalA.Runtime.Value1Evidence); - Assert.Null(canonicalB.Runtime.Value1Evidence); - } - - [Fact] - public void ManualOnlyLegacyProject_IsNotCanonicalizedByStaticDataSetMigration() - { - const string runtime = "AA1E1F06R4LD0/GGIO1.Ind1.stVal"; - var manual = ManualPoint("scl-manual-bbbbbbbbbbbbbbbbbbbb", runtime); - var project = Project(manual); - - var result = IoFatCanonicalEvidenceMigrationService.MigrateAndRemoveLegacyManualRows(project); - - Assert.Equal(0, result.RemovedManualRows); - Assert.Single(project.Ieds[0].TestPoints); - Assert.Same(manual, project.Ieds[0].TestPoints[0]); - } - - [Fact] - public void EngineeringBootstrap_EnforcesCanonicalRowCountBeforeProductionGridIsShown() - { - var source = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); - var synchronize = source.IndexOf("SynchronizeImportedSclFatWithEngineering(launch.Project);", StringComparison.Ordinal); - var migrate = source.IndexOf("MigrateAndRemoveLegacyManualRows(launch.Project)", StringComparison.Ordinal); - var invariant = source.IndexOf("launch.Project.SignalCount != canonicalStaticRowCount", StringComparison.Ordinal); - var show = source.IndexOf("await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0);", StringComparison.Ordinal); - - Assert.True(synchronize >= 0); - Assert.True(migrate > synchronize, "Legacy evidence migration must run after Engineering synchronization."); - Assert.True(invariant > migrate, "Canonical row-count invariant must run after legacy rows are removed."); - Assert.True(show > invariant, "The production FAT grid must not be exposed before canonical row-count validation."); - } - - private static IoTestProject Project(params IoTestPointPlan[] points) - => new() - { - ProjectId = "canonical-regression", - SchemaVersion = "ARSAS-FAT-SCL-1.0", - ProjectName = "Canonical regression", - Ieds = new List - { - new() - { - IedName = "AA1E1F06R4", - IpAddress = "192.168.81.103", - TestPoints = points.ToList() - } - } - }; - - private static IoTestPointPlan StaticPoint( - string id, - string runtimeReference, - string dataSet = "AA1E1F06R4LD0/LLN0.OperationalValues") - => new() - { - TestPointId = id, - IedName = "AA1E1F06R4", - IpAddress = "192.168.81.103", - SignalName = "Static member", - ObjectReference = runtimeReference, - FunctionalConstraint = "MX", - ExpectedOnText = "Value 1", - ExpectedOffText = "Value 2", - DataType = "FLOAT32", - SignalAddress = "source-sha", - DataSetName = dataSet, - SourceIecReference = runtimeReference, - ReportDisplayReference = runtimeReference, - EventLogSearchReference = runtimeReference, - SignalKind = FatSignalKind.Analog, - CaptureMode = FatCaptureMode.OperatorSnapshot, - WorkspaceSelected = true, - TestEnabled = true, - ImportReady = true, - BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus, - BindingEvidence = "Static SCL DataSet authority" - }; - - private static IoTestPointPlan ManualPoint(string id, string runtimeReference) - => new() - { - TestPointId = id, - IedName = "AA1E1F06R4", - IpAddress = "192.168.81.103", - SignalName = "Legacy manual alias", - ObjectReference = runtimeReference, - FunctionalConstraint = "MX", - ExpectedOnText = "Value 1", - ExpectedOffText = "Value 2", - DataType = "FLOAT32", - SignalAddress = "source-sha", - SourceIecReference = runtimeReference, - ReportDisplayReference = runtimeReference, - EventLogSearchReference = runtimeReference, - SignalKind = FatSignalKind.Analog, - CaptureMode = FatCaptureMode.OperatorSnapshot, - WorkspaceSelected = true, - TestEnabled = true, - ImportReady = true, - BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus, - BindingEvidence = "Shared SCL workspace authority" - }; - - private static void Bind(IoTestPointPlan point, string runtimeReference) - => point.ApplyLiveBinding( - IoTestLiveBindingState.LivePointReady, - "field-proven primary leaf", - "device-1", - runtimeReference); - - private static FatValueEvidence Evidence(FatValueSlot slot, string raw) - => new( - Guid.NewGuid(), - slot, - FatEvidenceCaptureKind.OperatorSnapshot, - raw, - DateTimeOffset.UtcNow, - DateTimeOffset.UtcNow, - "GOOD", - "regression", - 1, - 1); - - private static string Read(string relativePath) - => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); - - 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}'."); - } -} diff --git a/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs b/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs deleted file mode 100644 index e73742414..000000000 --- a/tests/ARSAS.Tests/IoFatEngineeringAuthorityNormalizationRegressionTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using ArIED61850Tester.Models.IoTesting; -using ArIED61850Tester.Services.IoTesting; - -namespace ARSAS.Tests; - -public sealed class IoFatEngineeringAuthorityNormalizationRegressionTests -{ - [Fact] - public void EngineeringProjectionAuthority_IsRecognizedBeforeLegacyManualMigration() - { - const string runtime = "AA1E1F06R4V1T3p1_OperationalValues/RPRE_MMXU1.A.phsA.cVal.mag.f"; - var canonical = new IoTestPointPlan - { - TestPointId = "scl-source-static-1", - IedName = "AA1E1F06R4", - IpAddress = "192.168.81.103", - SignalName = "Engineering static member", - ObjectReference = runtime, - FunctionalConstraint = "MX", - DataSetName = "AA1E1F06R4LD0/LLN0.OperationalValues", - SourceIecReference = runtime, - ReportDisplayReference = runtime, - EventLogSearchReference = runtime, - ExpectedOnText = "ON", - ExpectedOffText = "OFF", - WorkspaceSelected = true, - TestEnabled = true, - ImportReady = true, - BindingStatus = IoTestSignalSelectionService.EngineeringSclDataSetAuthorityBindingStatus - }; - canonical.ApplyLiveBinding( - IoTestLiveBindingState.LivePointReady, - "Engineering live point", - "device-1", - runtime); - - var manual = new IoTestPointPlan - { - TestPointId = "scl-manual-7496d038be4fdc18e340", - IedName = "AA1E1F06R4", - IpAddress = "192.168.81.103", - SignalName = "Restored legacy alias", - ObjectReference = runtime, - FunctionalConstraint = "MX", - SourceIecReference = runtime, - ReportDisplayReference = runtime, - EventLogSearchReference = runtime, - ExpectedOnText = "ON", - ExpectedOffText = "OFF", - WorkspaceSelected = true, - TestEnabled = true, - ImportReady = true, - BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus - }; - manual.ApplyLiveBinding( - IoTestLiveBindingState.LivePointReady, - "Legacy live alias", - "device-1", - runtime); - manual.Runtime.Value1Evidence = new FatValueEvidence( - Guid.NewGuid(), - FatValueSlot.Value1, - FatEvidenceCaptureKind.OperatorSnapshot, - "10.1", - DateTimeOffset.UtcNow, - DateTimeOffset.UtcNow, - "GOOD", - "field regression", - 1, - 1); - - var project = new IoTestProject - { - ProjectId = "field-authority-regression", - SchemaVersion = "ARSAS-FAT-SCL-1.0", - ProjectName = "Field authority regression", - Ieds = new List - { - new() - { - IedName = "AA1E1F06R4", - IpAddress = "192.168.81.103", - TestPoints = new List { canonical, manual } - } - } - }; - - // Critical field contract: Engineering projection rows must already be recognized - // as static DataSet authority before synchronize/migration runs. BindingStatus is - // immutable provenance and must not be rewritten later. - Assert.True(IoTestSignalSelectionService.IsSclDataSetAuthority(canonical)); - - var result = IoFatCanonicalEvidenceMigrationService.MigrateAndRemoveLegacyManualRows(project); - - Assert.Equal(1, result.RemovedManualRows); - Assert.Equal(1, result.MigratedEvidenceRows); - Assert.Single(project.Ieds[0].TestPoints); - Assert.Same(canonical, project.Ieds[0].TestPoints[0]); - Assert.Equal(IoTestSignalSelectionService.EngineeringSclDataSetAuthorityBindingStatus, canonical.BindingStatus); - Assert.True(IoTestSignalSelectionService.IsSclDataSetAuthority(canonical)); - Assert.NotNull(canonical.Runtime.Value1Evidence); - Assert.DoesNotContain(project.Ieds[0].TestPoints, IoFatCanonicalEvidenceMigrationService.IsLegacyManualWorkspaceRow); - } -} From 7bfec6cb57d43b401481853d7fba80643b3f50d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:26:28 +0700 Subject: [PATCH 077/158] test: align Engineering FAT regression with P5 bridge removal --- ...ductionFatEngineeringTabRegressionTests.cs | 48 ++----------------- 1 file changed, 4 insertions(+), 44 deletions(-) diff --git a/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs index 249579ca4..2045f0a77 100644 --- a/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs @@ -2,22 +2,6 @@ namespace ARSAS.Tests; public sealed class ProductionFatEngineeringTabRegressionTests { - [Fact] - public void EngineeringProjection_ReusesParsedSclWorkspaceWithoutOpeningXmlAgain() - { - // This service remains available to explicit/manual compatibility workflows. - // P5 removes it only from normal native FAT navigation/runtime ownership. - var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs")); - - Assert.Contains("device.SclWorkspace", source, StringComparison.Ordinal); - Assert.Contains("FatSclWorkspaceImportService.Import(workspaceSources)", source, StringComparison.Ordinal); - Assert.Contains("ENGINEERING_SCL_DATASET_AUTHORITY", source, StringComparison.Ordinal); - Assert.Contains("IoFatSourceWorkspaceService.DescribeAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("SclWorkspaceService", source, StringComparison.Ordinal); - Assert.DoesNotContain("OpenAsync(", source, StringComparison.Ordinal); - Assert.DoesNotContain("LoadScl", source, StringComparison.Ordinal); - } - [Fact] public void ProductionFatTab_NormalEntryHasNoLegacyProjectionBootstrapModule() { @@ -27,6 +11,9 @@ public void ProductionFatTab_NormalEntryHasNoLegacyProjectionBootstrapModule() Assert.False( File.Exists(Path.Combine(repoRoot, "MainWindow.ProductionFatEngineeringBootstrap.cs")), "P5 removes the automatic Engineering -> legacy IoTest bootstrap module from normal FAT navigation."); + Assert.False( + File.Exists(Path.Combine(repoRoot, "Services", "IoTesting", "IoFatEngineeringWorkspaceProjectionService.cs")), + "P5 removes the obsolete Engineering -> IoTest projection service rather than leaving a dormant second-row authority."); Assert.Contains("NativeFatTab.Content = BuildProductionFatPermanentHost();", source, StringComparison.Ordinal); Assert.Contains("SynchronizeProductionFatSelectedIed();", source, StringComparison.Ordinal); @@ -46,21 +33,11 @@ public void ProductionFatTab_NormalEntryHasNoLegacyProjectionBootstrapModule() Assert.DoesNotContain(forbidden, source, StringComparison.Ordinal); } - // Explicit/manual compatibility remains a deliberate, operator-invoked boundary. + // Explicit/manual compatibility remains a deliberate operator boundary only. Assert.Contains("MountProductionFatWorkspace", source, StringComparison.Ordinal); Assert.Contains("FAT compatibility workspace", source, StringComparison.Ordinal); } - [Fact] - public void ExplicitCompatibilityProjection_CanStillAdoptExactEngineeringRuntimeWorkspaces() - { - var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatSclProjectImportService.cs")); - - Assert.Contains("AdoptEngineeringRuntimeWorkspaces", source, StringComparison.Ordinal); - Assert.Contains("SetRuntimeWorkspaces(stable)", source, StringComparison.Ordinal); - Assert.Contains("workspace.WorkspaceKey", source, StringComparison.Ordinal); - } - [Fact] public void SeventhFatDestination_IsCanonicalMainWindowSiblingWithOneNavigationOwner() { @@ -108,23 +85,6 @@ public void ExistingWorkspaceSelectionSideEffects_RemainProtectedWhileAddingFat( Assert.Contains("UpdateNavigationVisuals(MainTabs.SelectedIndex, animate: true)", source, StringComparison.Ordinal); } - [Fact] - public void ExplicitLegacyMigrationSafety_RemainsAvailableWithoutOwningNormalFatRuntime() - { - var migrationSource = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatCanonicalEvidenceMigrationService.cs")); - var fieldRegressionSource = File.ReadAllText(FindRepoFile("tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs")); - var preflightSource = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestSessionPreflight.cs")); - var preflightTests = File.ReadAllText(FindRepoFile("tests/ARSAS.Tests/IoTestSessionPreflightTests.cs")); - - Assert.Contains("MigrateAndRemoveLegacyManualRows", migrationSource, StringComparison.Ordinal); - Assert.Contains("IsLegacyManualWorkspaceRow", migrationSource, StringComparison.Ordinal); - Assert.Contains("AutomaticStaticDataSetScope_RetiresManualAliasBeforeSessionPreflight", fieldRegressionSource, StringComparison.Ordinal); - Assert.Contains("IoTestSessionPreflight.Validate", fieldRegressionSource, StringComparison.Ordinal); - Assert.Contains("RetireRedundantManualWorkspaceRows", preflightSource, StringComparison.Ordinal); - Assert.Contains("multiple enabled test points", preflightSource, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Validate_RejectsDuplicateEnabledLiveReference", preflightTests, StringComparison.Ordinal); - } - private static string FindRepoFile(string relativePath) => Path.Combine(FindRepoRoot(), relativePath); From 3d59731da06c2ce09eb3b2dffd61f5173b0d3afe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:26:46 +0700 Subject: [PATCH 078/158] test: retire P0 bootstrap-only regressions after P5 --- .../ProductionFatP0FieldRegressionTests.cs | 76 ------------------- 1 file changed, 76 deletions(-) diff --git a/tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs index 9ea5e3463..a4d0d0dde 100644 --- a/tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs @@ -25,10 +25,6 @@ public void AutomaticStaticDataSetScope_RetiresManualAliasBeforeSessionPreflight primaryLiveLeaf); var ied = Ied(staticPoint, manualAlias); - - // Field regression: a persisted scl-manual-* row may be restored before live - // binding proves that both rows collapse to the same primary leaf. Start FAT must - // self-heal that stale overlay instead of presenting a scope-not-ready dialog. var ready = IoTestSessionPreflight.Validate(ied); Assert.True(ready.Succeeded, ready.Message); @@ -38,8 +34,6 @@ public void AutomaticStaticDataSetScope_RetiresManualAliasBeforeSessionPreflight Assert.True(manualAlias.IsIncludedInFat); Assert.Equal(primaryLiveLeaf, manualAlias.LiveSignalReference); - // The broad automatic-static cleanup is now idempotent because preflight already - // retired the exact live-leaf shadow alias without touching evidence/test state. var retired = IoFatEngineeringSelectionBridge.RetireManualWorkspaceRowsForStaticDataSetMode(ied); Assert.Equal(0, retired); } @@ -67,7 +61,6 @@ public void Preflight_LiveDuplicateGroup_RetiresRestoredManualIdEvenWhenLegacyBi primaryLiveLeaf); var ied = Ied(staticPoint, restoredManualAlias); - var ready = IoTestSessionPreflight.Validate(ied); Assert.True(ready.Succeeded, ready.Message); @@ -101,7 +94,6 @@ public void Preflight_TrueNonManualDuplicate_RemainsBlocked() primaryLiveLeaf); var ied = Ied(staticPoint, ambiguousLegacyPoint); - var blocked = IoTestSessionPreflight.Validate(ied); Assert.False(blocked.Succeeded); @@ -125,56 +117,6 @@ public void StaticDataSetModeCleanup_DoesNotTouchManualOnlyProjects() Assert.True(manual.IsIncludedInFat); } - [Fact] - public void EngineeringBootstrap_AppliesStaticCleanupBeforeProductionWindowIsShown() - { - var source = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); - var synchronize = source.IndexOf("SynchronizeImportedSclFatWithEngineering(launch.Project);", StringComparison.Ordinal); - var retire = source.IndexOf("RetireManualWorkspaceRowsForStaticDataSetMode", StringComparison.Ordinal); - var show = source.IndexOf("await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0);", StringComparison.Ordinal); - - Assert.True(synchronize >= 0, "Engineering/FAT synchronization call is missing."); - Assert.True(retire > synchronize, "Static DataSet cleanup must happen after shared selection synchronization so newly-created manual aliases are also retired."); - Assert.True(show > retire, "Static DataSet cleanup must complete before the production FAT workspace/session can be exposed."); - } - - [Fact] - public void EngineeringBootstrap_ReusesDescribedSourcesWhileStagingStillVerifiesSha256() - { - var projection = Read("Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs"); - var bootstrap = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); - var bootstrapService = Read("Services/IoTesting/IoTestWorkspaceBootstrapService.cs"); - var persistence = Read("Services/IoTesting/IoTestProjectPersistenceService.cs"); - var sourceWorkspace = Read("Services/IoTesting/IoFatSourceWorkspaceService.cs"); - - Assert.Contains("IReadOnlyList DescribedSources", projection, StringComparison.Ordinal); - Assert.Equal( - 1, - projection.Split("IoFatSourceWorkspaceService.DescribeAsync", StringSplitOptions.None).Length - 1); - Assert.Contains("projection.DescribedSources", bootstrap, StringComparison.Ordinal); - Assert.Contains("IoTestWorkspaceBootstrapService.OpenDescribedSourcesAsync", bootstrap, StringComparison.Ordinal); - Assert.Contains("OpenDescribedSourcesAsync", bootstrapService, StringComparison.Ordinal); - Assert.Contains("IoTestWorkspacePersistence.OpenDescribedSourcesAsync", bootstrapService, StringComparison.Ordinal); - Assert.Contains("StageDescribedAsync", persistence, StringComparison.Ordinal); - Assert.Contains("CopyVerifiedAsync", sourceWorkspace, StringComparison.Ordinal); - Assert.Contains("IsVerifiedStagedCopyAsync", sourceWorkspace, StringComparison.Ordinal); - Assert.Contains("SHA256.HashDataAsync(stream", sourceWorkspace, StringComparison.Ordinal); - Assert.Contains("VerifyHash(bytes, expectedSha256", sourceWorkspace, StringComparison.Ordinal); - } - - [Fact] - public void EmbeddedAutomaticBootstrap_NeverHidesEngineeringWindow() - { - var source = Read("MainWindow.ProductionFatNoFlicker.cs"); - - Assert.Contains("public new void Hide()", source, StringComparison.Ordinal); - Assert.Contains("ShouldKeepEngineeringVisibleDuringProductionFatBootstrap", source, StringComparison.Ordinal); - Assert.Contains("_productionFatEngineeringBootstrapBusy", source, StringComparison.Ordinal); - Assert.Contains("ProductionFatTabReady", source, StringComparison.Ordinal); - Assert.DoesNotContain("MainTabs.SelectedIndex == NativeFatWorkspaceIndex", source, StringComparison.Ordinal); - Assert.Contains("base.Hide();", source, StringComparison.Ordinal); - } - private static IoTestPointPlan StaticPoint(string staticReference) => new() { @@ -237,22 +179,4 @@ private static IoTestIedPlan Ied(params IoTestPointPlan[] points) IpAddress = "192.168.81.103", TestPoints = points.ToList() }; - - private static string Read(string relativePath) - => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); - - 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}'."); - } } From b1e4600f7da889088b885e88892ac2929e5381cf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:27:05 +0700 Subject: [PATCH 079/158] test: make M7 cleanup enforce P5 bootstrap retirement --- .../ProductionFatM7CleanupRegressionTests.cs | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/tests/ARSAS.Tests/ProductionFatM7CleanupRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatM7CleanupRegressionTests.cs index f0e993000..1296e3633 100644 --- a/tests/ARSAS.Tests/ProductionFatM7CleanupRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatM7CleanupRegressionTests.cs @@ -9,24 +9,12 @@ public void ObsoleteNativeFatRuntime_IsNotASecondProductionAuthority() var bridge = File.ReadAllText(Path.Combine(repoRoot, "MainWindow.NativeFatWorkspace.cs")); var productionTab = File.ReadAllText(Path.Combine(repoRoot, "MainWindow.ProductionFatTab.cs")); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatExport.cs")), - "The retired native FAT export must not return; production FAT owns report/export delivery."); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatReportPreview.cs")), - "The retired native FAT side-panel preview must not return. Production FAT owns one in-place report preview."); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatHistoryInspector.cs")), - "The retired side-panel history inspector must not return as a second FAT presentation stack."); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatExplorerSync.cs")), - "The retired native FAT Explorer reconciler must not return; Engineering selection and production FAT own synchronization."); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatPersistenceSafety.cs")), - "The retired native FAT persistence runtime must not return; production FAT storage remains the persistence authority."); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatExport.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatReportPreview.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatHistoryInspector.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatExplorerSync.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatPersistenceSafety.cs"))); - // MainWindow.NativeFatWorkspace.cs is now only a compatibility bridge for the - // canonical seventh shell slot. It must never regain its own FAT runtime/state. Assert.Contains("private const int NativeFatWorkspaceIndex = 6", bridge, StringComparison.Ordinal); Assert.Contains("QueueNativeFatNavigationGeometry", bridge, StringComparison.Ordinal); Assert.DoesNotContain("DataGrid", bridge, StringComparison.Ordinal); @@ -38,8 +26,6 @@ public void ObsoleteNativeFatRuntime_IsNotASecondProductionAuthority() Assert.DoesNotContain("BuildNativeFatWorkspaceContent", bridge, StringComparison.Ordinal); Assert.DoesNotContain("RegisterNativeFatWorkspace", bridge, StringComparison.Ordinal); - // Production FAT mounts directly into the canonical XAML slot. There must be no - // compatibility-field handshake with the retired native runtime. Assert.Contains("NativeFatTab.Content = BuildProductionFatPermanentHost();", productionTab, StringComparison.Ordinal); Assert.Contains("NativeFatTab.Content = surface;", productionTab, StringComparison.Ordinal); Assert.Contains("NavNativeFatButton.ToolTip", productionTab, StringComparison.Ordinal); @@ -52,7 +38,7 @@ public void ObsoleteNativeFatRuntime_IsNotASecondProductionAuthority() } [Fact] - public void ProductionReportPreviewAndExport_RemainSingleAuthority() + public void ExplicitCompatibilityReportPreviewAndExport_RemainAvailableAtBoundary() { var repoRoot = FindRepoRoot(); var productionPreview = File.ReadAllText(Path.Combine(repoRoot, "IoListTestingWindow.PrintPreview.cs")); @@ -67,13 +53,17 @@ public void ProductionReportPreviewAndExport_RemainSingleAuthority() } [Fact] - public void Cleanup_DoesNotRetireProductionCapturePreflightOrEngineeringAcquisitionAuthority() + public void Cleanup_RetiresAutomaticBootstrapButKeepsExplicitProductionPreflightAuthority() { - var bootstrap = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatEngineeringBootstrap.cs")); + var repoRoot = FindRepoRoot(); var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatProductionControllerAdapter.cs")); var contract = File.ReadAllText(FindRepoFile("docs/FAT_ENGINEERING_WORKSTATION_CONTRACT.md")); - Assert.Contains("AdoptEngineeringRuntimeWorkspaces", bootstrap, StringComparison.Ordinal); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.ProductionFatEngineeringBootstrap.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.ProductionFatNoFlicker.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "Services", "IoTesting", "IoFatEngineeringWorkspaceProjectionService.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "Services", "IoTesting", "IoFatCanonicalEvidenceMigrationService.cs"))); + Assert.Contains("IoTestSessionPreflight.Validate", adapter, StringComparison.Ordinal); Assert.Contains("IoFatProductionControllerAdapter", adapter, StringComparison.Ordinal); Assert.Contains("production FAT", contract, StringComparison.OrdinalIgnoreCase); From 2619d2c6666da4767ea5f17ba594b9d0dda1a060 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:27:21 +0700 Subject: [PATCH 080/158] test: make permanent-host regression follow P5 native entry --- ...uctionFatM2PermanentHostRegressionTests.cs | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/tests/ARSAS.Tests/ProductionFatM2PermanentHostRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatM2PermanentHostRegressionTests.cs index 5f113b828..4583336da 100644 --- a/tests/ARSAS.Tests/ProductionFatM2PermanentHostRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatM2PermanentHostRegressionTests.cs @@ -16,36 +16,26 @@ public void FatTab_IsPermanentHost_NotLauncherOrAlternateOpenSclWorkflow() } [Fact] - public void EngineeringStaticDataSet_FatBootstrapIsNavigationGated() + public void NormalFatEntry_HasNoAutomaticEngineeringBootstrapOwner() { - var bootstrap = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); - - Assert.DoesNotContain( - "window.QueueProductionFatEngineeringBootstrap();\n }\n\n private void ProductionFatEngineeringBootstrap_SelectionChanged", - bootstrap, - StringComparison.Ordinal); - Assert.Contains( - "e.PropertyName != nameof(SelectedDevice) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex", - bootstrap, - StringComparison.Ordinal); - Assert.Contains( - "!_productionFatEngineeringBootstrapInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex", - bootstrap, - StringComparison.Ordinal); - Assert.Contains( - "_productionFatEngineeringBootstrapBusy ||\n MainTabs.SelectedIndex != NativeFatWorkspaceIndex", - bootstrap, - StringComparison.Ordinal); + var root = FindRepoRoot(); + var tab = Read("MainWindow.ProductionFatTab.cs"); + + Assert.False(File.Exists(Path.Combine(root, "MainWindow.ProductionFatEngineeringBootstrap.cs"))); + Assert.False(File.Exists(Path.Combine(root, "MainWindow.ProductionFatNoFlicker.cs"))); + Assert.False(File.Exists(Path.Combine(root, "Services", "IoTesting", "IoFatEngineeringWorkspaceProjectionService.cs"))); + + Assert.Contains("BuildNativeFatCanonicalWorkspace()", tab, StringComparison.Ordinal); + Assert.Contains("BindNativeFatCanonicalRows();", tab, StringComparison.Ordinal); + Assert.DoesNotContain("QueueProductionFatEngineeringBootstrap", tab, StringComparison.Ordinal); + Assert.DoesNotContain("ShowIoTestingWorkspaceAsync", tab, StringComparison.Ordinal); } [Fact] - public void AutomaticFatBootstrap_PreservesStaticReportOnlyAuthority() + public void ExplicitCompatibilityStaticReportOnlyAuthority_RemainsAvailableAtBoundary() { - var bootstrap = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); var authority = Read("MainWindow.SharedStaticDataSetAuthority.cs"); - Assert.Contains("PreserveSharedStaticDataSetAuthority(device);", bootstrap, StringComparison.Ordinal); - Assert.DoesNotContain("MarkSharedSelectionAuthority(device);", bootstrap, StringComparison.Ordinal); Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device);", authority, StringComparison.Ordinal); Assert.DoesNotContain("UseHybrid", authority, StringComparison.Ordinal); Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId);", authority, StringComparison.Ordinal); From 24cffc1467c8321c19f75308537e904eeeeea39f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:27:39 +0700 Subject: [PATCH 081/158] test: close P5 legacy bridge removal gate --- .../NativeFatP5LegacyBridgeRemovalTests.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs b/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs index 8870da690..e4882c156 100644 --- a/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs +++ b/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs @@ -51,12 +51,26 @@ public void P5_NormalNativeFatRuntimeHasNoProjectionBootstrapReconnectOrSecondAc } [Fact] - public void P5_AutomaticEngineeringBootstrapModuleIsPhysicallyRemoved() + public void P5_ObsoleteAutomaticBridgeFilesAndProjectionAuthorityArePhysicallyRemoved() { var root = FindRepoRoot(); - Assert.False( - File.Exists(Path.Combine(root, "MainWindow.ProductionFatEngineeringBootstrap.cs")), - "Normal native FAT must not regain the automatic Engineering -> legacy IoTest bootstrap module."); + var selection = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestSignalSelectionService.cs")); + + foreach (var relativePath in new[] + { + "MainWindow.ProductionFatEngineeringBootstrap.cs", + "MainWindow.ProductionFatNoFlicker.cs", + Path.Combine("Services", "IoTesting", "IoFatEngineeringWorkspaceProjectionService.cs"), + Path.Combine("Services", "IoTesting", "IoFatCanonicalEvidenceMigrationService.cs") + }) + { + Assert.False( + File.Exists(Path.Combine(root, relativePath)), + $"P5 requires obsolete automatic FAT bridge file '{relativePath}' to remain retired."); + } + + Assert.DoesNotContain("ENGINEERING_SCL_DATASET_AUTHORITY", selection, StringComparison.Ordinal); + Assert.DoesNotContain("EngineeringSclDataSetAuthorityBindingStatus", selection, StringComparison.Ordinal); } [Fact] From 3375654d16459cddacd018805b5c9fb250fbfedb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:29:46 +0700 Subject: [PATCH 082/158] FAT P5: retire zero-reparse Engineering workspace adoption bridge --- .../IoTesting/IoFatSclProjectImportService.cs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/Services/IoTesting/IoFatSclProjectImportService.cs b/Services/IoTesting/IoFatSclProjectImportService.cs index da208b075..398915baf 100644 --- a/Services/IoTesting/IoFatSclProjectImportService.cs +++ b/Services/IoTesting/IoFatSclProjectImportService.cs @@ -67,22 +67,6 @@ internal bool TryGetRuntimeWorkspace( } } - /// - /// Registers ARIEC workspaces that are already owned by Engineering. This is the - /// zero-reparse bridge used by the embedded FAT tab: the exact SclIedWorkspace objects - /// already attached to Explorer devices become the production FAT runtime authority. - /// - internal void AdoptEngineeringRuntimeWorkspaces(IEnumerable workspaces) - { - ArgumentNullException.ThrowIfNull(workspaces); - var stable = workspaces - .Where(workspace => workspace != null) - .GroupBy(workspace => workspace.WorkspaceKey, StringComparer.OrdinalIgnoreCase) - .Select(group => group.Last()) - .ToArray(); - SetRuntimeWorkspaces(stable); - } - public Task ImportAsync( IReadOnlyCollection sclPaths, string? projectName = null, From 2040ba910a01f7ddef3c1232449b54c233935880 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:30:13 +0700 Subject: [PATCH 083/158] test: lock retired Engineering workspace adoption bridge --- tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs b/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs index e4882c156..9fe49b698 100644 --- a/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs +++ b/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs @@ -55,6 +55,7 @@ public void P5_ObsoleteAutomaticBridgeFilesAndProjectionAuthorityArePhysicallyRe { var root = FindRepoRoot(); var selection = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestSignalSelectionService.cs")); + var importer = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatSclProjectImportService.cs")); foreach (var relativePath in new[] { @@ -71,6 +72,7 @@ public void P5_ObsoleteAutomaticBridgeFilesAndProjectionAuthorityArePhysicallyRe Assert.DoesNotContain("ENGINEERING_SCL_DATASET_AUTHORITY", selection, StringComparison.Ordinal); Assert.DoesNotContain("EngineeringSclDataSetAuthorityBindingStatus", selection, StringComparison.Ordinal); + Assert.DoesNotContain("AdoptEngineeringRuntimeWorkspaces", importer, StringComparison.Ordinal); } [Fact] From faad2ffaf3ba3cc47507d49ef49e71933a759af3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:46:26 +0700 Subject: [PATCH 084/158] FAT P5: refresh canonical FAT after static authority update --- MainWindow.SharedSclWorkspace.cs | 238 ++++++++++--------------------- 1 file changed, 75 insertions(+), 163 deletions(-) diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index 1fcf55dd0..d8bac0c27 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -1,72 +1,65 @@ -using System.IO; -using System.Windows; using ArIED61850Tester.Models; -using ArIED61850Tester.Models.IoTesting; using ArIED61850Tester.Services; -using ArIED61850Tester.Services.IoTesting; namespace ArIED61850Tester; public partial class MainWindow { - private enum SclSignalSelectionMode + private readonly HashSet _sharedSclSelectionAuthorityDeviceIds = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _sharedSclStaticDataSetAuthorityDeviceIds = new(StringComparer.OrdinalIgnoreCase); + private int _pendingSharedStaticSelectionAssignments; + + private bool UsesSharedSclSelectionAuthority(Iec61850MonitorDevice? device) + => device != null && _sharedSclSelectionAuthorityDeviceIds.Contains(device.DeviceId); + + private bool UsesSharedStaticDataSetAuthority(Iec61850MonitorDevice? device) + => device != null && _sharedSclStaticDataSetAuthorityDeviceIds.Contains(device.DeviceId); + + private void SynchronizeAllEngineeringSelectionsToFat(Iec61850MonitorDevice? device) { - StaticDataSet, - Manual - } + if (device == null) + return; - // This records that the operator has made an explicit selection decision for the - // shared SCL device. It is intentionally separate from "any selected signal" so an - // intentionally empty manual selection is preserved when Engineering opens FAT. - private readonly HashSet _sharedSclSelectionAuthorityDeviceIds = - new(StringComparer.OrdinalIgnoreCase); - - // Keep the operator's acquisition intent independently of transient runtime teardown. - // FAT and Engineering share one device/workspace, so entering FAT must never demote an - // explicitly selected Static DataSet report-only workspace into generic Hybrid/MMS. - private readonly HashSet _sharedSclStaticDataSetAuthorityDeviceIds = - new(StringComparer.OrdinalIgnoreCase); - - // Prompted Static DataSet selection is consumed by the same number of IEDs that were - // presented in that decision. This closes the golden #1888 hole where the FAT import - // path called MarkSharedSelectionAuthority and accidentally downgraded an explicit - // Static DataSet choice back to Hybrid before the window was shown. - private int _pendingSharedStaticSelectionAssignments; + foreach (var signal in device.Signals) + signal.IsSelectedForFat = signal.IsSelected; + } - private bool IsSharedStaticDataSetAuthority(Iec61850MonitorDevice device) - => _sharedSclStaticDataSetAuthorityDeviceIds.Contains(device.DeviceId) || - Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device); + private void SetSharedSclSelectionAuthority(Iec61850MonitorDevice device, bool enabled) + { + if (enabled) + _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); + else + _sharedSclSelectionAuthorityDeviceIds.Remove(device.DeviceId); + } - private SclSignalSelectionMode? PromptSclSignalSelectionMode(Window owner, int iedCount) + private void SetSharedStaticDataSetAuthority(Iec61850MonitorDevice device, bool enabled) { - var dialog = new SclSignalSelectionModeWindow(iedCount) - { - Owner = owner - }; - if (dialog.ShowDialog() != true) - { - _pendingSharedStaticSelectionAssignments = 0; - return null; - } + if (enabled) + _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId); + else + _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId); + } - var mode = dialog.UseStaticDataSet - ? SclSignalSelectionMode.StaticDataSet - : SclSignalSelectionMode.Manual; - _pendingSharedStaticSelectionAssignments = mode == SclSignalSelectionMode.StaticDataSet - ? Math.Max(1, iedCount) - : 0; - return mode; + private void PreserveSharedStaticDataSetAuthority(Iec61850MonitorDevice device) + { + SetSharedSclSelectionAuthority(device, true); + SetSharedStaticDataSetAuthority(device, true); + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); } - private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) + private void ApplySharedStaticDataSetSelectionAuthority( + Iec61850MonitorDevice device, + SclWorkspaceStaticDataSetMergeResult merge) { - // Static DataSet remains the protocol authority established by the report-only - // baseline. Materialize every ARIEC-owned member first, then select exactly one - // presentation/runtime row per literal static membership. A browsed alias carrying - // DataSetReference is not enough authority and must not inflate the live plan. - var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); - RegisterRecoveredDataSetSignals(device, merge); - var authoritativeSignals = Iec61850StaticDataSetAuthoritySelection.Build(device); + if (device == null) + return; + + var authoritativeSignals = merge.AuthoritativeSignals + .Where(signal => signal != null) + .ToHashSet(); + + // Static DataSet mode is report-only by design. Selection here identifies exact + // report membership and must never fall through to cyclic process polling. Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); device.BeginBulkSignalSelection(); @@ -99,10 +92,10 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) LogStaticDataSetReportFeasibility(device); _ = ObserveInitialStaticReportEvidenceAsync(device); - // M2 permanent FAT host: authority establishment is also a readiness trigger. - // This covers SCL refresh on the same SelectedDevice, where PropertyChanged for - // SelectedDevice would otherwise not fire. - QueueProductionFatEngineeringBootstrap(); + // P5 native FAT is a thin view over SelectedDevice.Points. Re-synchronize the + // canonical view after static DataSet authority refresh so a same-IED SCL refresh + // is visible immediately without reviving the retired Engineering -> IoTest bootstrap. + SynchronizeProductionFatSelectedIed(); } private void ClearSharedSignalSelection(Iec61850MonitorDevice device) @@ -123,121 +116,40 @@ private void MarkSharedSelectionAuthority(Iec61850MonitorDevice device) { // The initial FAT import historically reached this helper for both branches. If the // immediately preceding operator decision was Static DataSet, preserve that explicit - // report-only authority instead of silently demoting it to Hybrid. - if (_pendingSharedStaticSelectionAssignments > 0) + // report-only authority instead of silently downgrading it to shared polling mode. + if (UsesSharedStaticDataSetAuthority(device) || _pendingSharedStaticSelectionAssignments > 0) { - ApplyStaticDataSetSelection(device); + PreserveSharedStaticDataSetAuthority(device); + if (_pendingSharedStaticSelectionAssignments > 0) + _pendingSharedStaticSelectionAssignments--; return; } - // Manual selection restores the normal Smart/Hybrid acquisition contract. - _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId); + SetSharedSclSelectionAuthority(device, true); + SetSharedStaticDataSetAuthority(device, false); + Iec61850MonitoringModeRegistry.UseSharedSelection(device); + } + + private void ClearSharedSelectionAuthority(Iec61850MonitorDevice device) + { + SetSharedSclSelectionAuthority(device, false); + SetSharedStaticDataSetAuthority(device, false); Iec61850MonitoringModeRegistry.UseHybrid(device); - _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); - SaveSignalSelectionMemory(device); } - private string[] CurrentEngineeringSclSourcePaths() - => Devices - .Select(device => device.SclSourcePath) - .Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - private void RegisterSharedSclSourcePaths( - IoTestProject project, - IEnumerable ieds, - IReadOnlyCollection sourceInputs) + private void ApplySharedSclSelectionAuthority(Iec61850MonitorDevice device) { - var uniquePathByFileName = sourceInputs - .GroupBy(input => Path.GetFileName(input.FilePath), StringComparer.OrdinalIgnoreCase) - .Where(group => group.Count() == 1) - .ToDictionary( - group => group.Key, - group => Path.GetFullPath(group.Single().FilePath), - StringComparer.OrdinalIgnoreCase); - var sourceById = project.Sources.ToDictionary(source => source.SourceId, StringComparer.OrdinalIgnoreCase); - - foreach (var ied in ieds) - { - var device = ResolveIoTestDevice(ied.LiveDeviceId) - ?? ResolveIoTestDevice(ied.IpAddress) - ?? ResolveIoTestDevice(ied.IedName); - if (device is null) - continue; - - IoFatSourceDescriptor? source = null; - var sourceId = ied.TestPoints - .Select(point => point.SignalAddress) - .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && sourceById.ContainsKey(value)); - if (!string.IsNullOrWhiteSpace(sourceId)) - source = sourceById[sourceId]; - - // Manual-only SCL workspaces can legitimately have zero static DataSet rows, - // so source identity must not depend on finding a FAT point first. - if (source is null && !string.IsNullOrWhiteSpace(device.SclSourceSha256)) - { - source = project.Sources.FirstOrDefault(candidate => candidate.Sha256.Equals( - device.SclSourceSha256, - StringComparison.OrdinalIgnoreCase)); - } - if (source is null && project.Sources.Count == 1) - source = project.Sources[0]; - if (source is null && !string.IsNullOrWhiteSpace(device.SclSourcePath)) - { - var currentName = Path.GetFileName(device.SclSourcePath); - source = project.Sources.FirstOrDefault(candidate => candidate.FileName.Equals( - currentName, - StringComparison.OrdinalIgnoreCase)); - } - if (source is null || !uniquePathByFileName.TryGetValue(source.FileName, out var sourcePath)) - continue; - - device.SclSourcePath = sourcePath; - device.SclSourceSha256 = source.Sha256; - } + SynchronizeAllEngineeringSelectionsToFat(device); + MarkSharedSelectionAuthority(device); + SaveSignalSelectionMemory(device); + device.RefreshComputed(); } - private async Task ApplyManualSelectionToFatProjectAsync( - IoTestProject project, - IEnumerable ieds, - Window owner, - bool resetSelection) + private void ClearSharedSclSelectionAuthority(Iec61850MonitorDevice device) { - foreach (var ied in ieds) - { - var device = ResolveIoTestDevice(ied.LiveDeviceId) - ?? ResolveIoTestDevice(ied.IpAddress) - ?? ResolveIoTestDevice(ied.IedName); - if (device is null) - continue; - - if (resetSelection) - { - ClearSharedSignalSelection(device); - foreach (var point in ied.TestPoints) - point.WorkspaceSelected = false; - } - - await OpenSignalSelectionWizardAsync( - device, - autoStartAfterSave: false, - ownerOverride: owner); - - // The FAT window is not yet attached during an initial FAT import, so perform - // the same bridge operation explicitly. Selected non-DataSet SCL signals are - // materialized here as persistent FAT rows; existing FAT TEST/disposition state - // is never rewritten by Engineering selection. - foreach (var signal in device.Signals) - { - IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection( - signal, - signal.IsSelected, - ied, - device); - } - - MarkSharedSelectionAuthority(device); - } + ClearSharedSignalSelection(device); + ClearSharedSelectionAuthority(device); + SaveSignalSelectionMemory(device); + device.RefreshComputed(); } -} \ No newline at end of file +} From 367db09ea3f69b14840437a1b7e1fd658e6899ca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 11:47:01 +0700 Subject: [PATCH 085/158] fix: preserve shared SCL workspace while removing bootstrap caller --- MainWindow.SharedSclWorkspace.cs | 234 +++++++++++++++++++++---------- 1 file changed, 161 insertions(+), 73 deletions(-) diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index d8bac0c27..7be2401f6 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -1,65 +1,72 @@ +using System.IO; +using System.Windows; using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; using ArIED61850Tester.Services; +using ArIED61850Tester.Services.IoTesting; namespace ArIED61850Tester; public partial class MainWindow { - private readonly HashSet _sharedSclSelectionAuthorityDeviceIds = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _sharedSclStaticDataSetAuthorityDeviceIds = new(StringComparer.OrdinalIgnoreCase); - private int _pendingSharedStaticSelectionAssignments; - - private bool UsesSharedSclSelectionAuthority(Iec61850MonitorDevice? device) - => device != null && _sharedSclSelectionAuthorityDeviceIds.Contains(device.DeviceId); - - private bool UsesSharedStaticDataSetAuthority(Iec61850MonitorDevice? device) - => device != null && _sharedSclStaticDataSetAuthorityDeviceIds.Contains(device.DeviceId); - - private void SynchronizeAllEngineeringSelectionsToFat(Iec61850MonitorDevice? device) + private enum SclSignalSelectionMode { - if (device == null) - return; - - foreach (var signal in device.Signals) - signal.IsSelectedForFat = signal.IsSelected; + StaticDataSet, + Manual } - private void SetSharedSclSelectionAuthority(Iec61850MonitorDevice device, bool enabled) - { - if (enabled) - _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); - else - _sharedSclSelectionAuthorityDeviceIds.Remove(device.DeviceId); - } + // This records that the operator has made an explicit selection decision for the + // shared SCL device. It is intentionally separate from "any selected signal" so an + // intentionally empty manual selection is preserved when Engineering opens FAT. + private readonly HashSet _sharedSclSelectionAuthorityDeviceIds = + new(StringComparer.OrdinalIgnoreCase); + + // Keep the operator's acquisition intent independently of transient runtime teardown. + // FAT and Engineering share one device/workspace, so entering FAT must never demote an + // explicitly selected Static DataSet report-only workspace into generic Hybrid/MMS. + private readonly HashSet _sharedSclStaticDataSetAuthorityDeviceIds = + new(StringComparer.OrdinalIgnoreCase); + + // Prompted Static DataSet selection is consumed by the same number of IEDs that were + // presented in that decision. This closes the golden #1888 hole where the FAT import + // path called MarkSharedSelectionAuthority and accidentally downgraded an explicit + // Static DataSet choice back to Hybrid before the window was shown. + private int _pendingSharedStaticSelectionAssignments; - private void SetSharedStaticDataSetAuthority(Iec61850MonitorDevice device, bool enabled) - { - if (enabled) - _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId); - else - _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId); - } + private bool IsSharedStaticDataSetAuthority(Iec61850MonitorDevice device) + => _sharedSclStaticDataSetAuthorityDeviceIds.Contains(device.DeviceId) || + Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device); - private void PreserveSharedStaticDataSetAuthority(Iec61850MonitorDevice device) + private SclSignalSelectionMode? PromptSclSignalSelectionMode(Window owner, int iedCount) { - SetSharedSclSelectionAuthority(device, true); - SetSharedStaticDataSetAuthority(device, true); - Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + var dialog = new SclSignalSelectionModeWindow(iedCount) + { + Owner = owner + }; + if (dialog.ShowDialog() != true) + { + _pendingSharedStaticSelectionAssignments = 0; + return null; + } + + var mode = dialog.UseStaticDataSet + ? SclSignalSelectionMode.StaticDataSet + : SclSignalSelectionMode.Manual; + _pendingSharedStaticSelectionAssignments = mode == SclSignalSelectionMode.StaticDataSet + ? Math.Max(1, iedCount) + : 0; + return mode; } - private void ApplySharedStaticDataSetSelectionAuthority( - Iec61850MonitorDevice device, - SclWorkspaceStaticDataSetMergeResult merge) + private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) { - if (device == null) - return; - - var authoritativeSignals = merge.AuthoritativeSignals - .Where(signal => signal != null) - .ToHashSet(); - - // Static DataSet mode is report-only by design. Selection here identifies exact - // report membership and must never fall through to cyclic process polling. + // Static DataSet remains the protocol authority established by the report-only + // baseline. Materialize every ARIEC-owned member first, then select exactly one + // presentation/runtime row per literal static membership. A browsed alias carrying + // DataSetReference is not enough authority and must not inflate the live plan. + var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + RegisterRecoveredDataSetSignals(device, merge); + var authoritativeSignals = Iec61850StaticDataSetAuthoritySelection.Build(device); Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); device.BeginBulkSignalSelection(); @@ -92,9 +99,9 @@ private void ApplySharedStaticDataSetSelectionAuthority( LogStaticDataSetReportFeasibility(device); _ = ObserveInitialStaticReportEvidenceAsync(device); - // P5 native FAT is a thin view over SelectedDevice.Points. Re-synchronize the - // canonical view after static DataSet authority refresh so a same-IED SCL refresh - // is visible immediately without reviving the retired Engineering -> IoTest bootstrap. + // P5 native FAT is a thin view over SelectedDevice.Points. Re-synchronize after + // static DataSet authority refresh so same-IED SCL refreshes are visible without + // reviving the retired Engineering -> legacy IoTest bootstrap. SynchronizeProductionFatSelectedIed(); } @@ -116,40 +123,121 @@ private void MarkSharedSelectionAuthority(Iec61850MonitorDevice device) { // The initial FAT import historically reached this helper for both branches. If the // immediately preceding operator decision was Static DataSet, preserve that explicit - // report-only authority instead of silently downgrading it to shared polling mode. - if (UsesSharedStaticDataSetAuthority(device) || _pendingSharedStaticSelectionAssignments > 0) + // report-only authority instead of silently demoting it to Hybrid. + if (_pendingSharedStaticSelectionAssignments > 0) { - PreserveSharedStaticDataSetAuthority(device); - if (_pendingSharedStaticSelectionAssignments > 0) - _pendingSharedStaticSelectionAssignments--; + ApplyStaticDataSetSelection(device); return; } - SetSharedSclSelectionAuthority(device, true); - SetSharedStaticDataSetAuthority(device, false); - Iec61850MonitoringModeRegistry.UseSharedSelection(device); - } - - private void ClearSharedSelectionAuthority(Iec61850MonitorDevice device) - { - SetSharedSclSelectionAuthority(device, false); - SetSharedStaticDataSetAuthority(device, false); + // Manual selection restores the normal Smart/Hybrid acquisition contract. + _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId); Iec61850MonitoringModeRegistry.UseHybrid(device); + _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); + SaveSignalSelectionMemory(device); } - private void ApplySharedSclSelectionAuthority(Iec61850MonitorDevice device) + private string[] CurrentEngineeringSclSourcePaths() + => Devices + .Select(device => device.SclSourcePath) + .Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private void RegisterSharedSclSourcePaths( + IoTestProject project, + IEnumerable ieds, + IReadOnlyCollection sourceInputs) { - SynchronizeAllEngineeringSelectionsToFat(device); - MarkSharedSelectionAuthority(device); - SaveSignalSelectionMemory(device); - device.RefreshComputed(); + var uniquePathByFileName = sourceInputs + .GroupBy(input => Path.GetFileName(input.FilePath), StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() == 1) + .ToDictionary( + group => group.Key, + group => Path.GetFullPath(group.Single().FilePath), + StringComparer.OrdinalIgnoreCase); + var sourceById = project.Sources.ToDictionary(source => source.SourceId, StringComparer.OrdinalIgnoreCase); + + foreach (var ied in ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is null) + continue; + + IoFatSourceDescriptor? source = null; + var sourceId = ied.TestPoints + .Select(point => point.SignalAddress) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && sourceById.ContainsKey(value)); + if (!string.IsNullOrWhiteSpace(sourceId)) + source = sourceById[sourceId]; + + // Manual-only SCL workspaces can legitimately have zero static DataSet rows, + // so source identity must not depend on finding a FAT point first. + if (source is null && !string.IsNullOrWhiteSpace(device.SclSourceSha256)) + { + source = project.Sources.FirstOrDefault(candidate => candidate.Sha256.Equals( + device.SclSourceSha256, + StringComparison.OrdinalIgnoreCase)); + } + if (source is null && project.Sources.Count == 1) + source = project.Sources[0]; + if (source is null && !string.IsNullOrWhiteSpace(device.SclSourcePath)) + { + var currentName = Path.GetFileName(device.SclSourcePath); + source = project.Sources.FirstOrDefault(candidate => candidate.FileName.Equals( + currentName, + StringComparison.OrdinalIgnoreCase)); + } + if (source is null || !uniquePathByFileName.TryGetValue(source.FileName, out var sourcePath)) + continue; + + device.SclSourcePath = sourcePath; + device.SclSourceSha256 = source.Sha256; + } } - private void ClearSharedSclSelectionAuthority(Iec61850MonitorDevice device) + private async Task ApplyManualSelectionToFatProjectAsync( + IoTestProject project, + IEnumerable ieds, + Window owner, + bool resetSelection) { - ClearSharedSignalSelection(device); - ClearSharedSelectionAuthority(device); - SaveSignalSelectionMemory(device); - device.RefreshComputed(); + foreach (var ied in ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is null) + continue; + + if (resetSelection) + { + ClearSharedSignalSelection(device); + foreach (var point in ied.TestPoints) + point.WorkspaceSelected = false; + } + + await OpenSignalSelectionWizardAsync( + device, + autoStartAfterSave: false, + ownerOverride: owner); + + // The FAT window is not yet attached during an initial FAT import, so perform + // the same bridge operation explicitly. Selected non-DataSet SCL signals are + // materialized here as persistent FAT rows; existing FAT TEST/disposition state + // is never rewritten by Engineering selection. + foreach (var signal in device.Signals) + { + IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection( + signal, + signal.IsSelected, + ied, + device); + } + + MarkSharedSelectionAuthority(device); + } } } From 6ea69c74d66c1aeca5ce1ce9b10543dfecda1e9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 12:05:21 +0700 Subject: [PATCH 086/158] test: make P4E feedback fallback guard comment-safe --- .../NativeFatP4ECommandFeedbackCorrelationTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs b/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs index e75ada54a..b9391f8e6 100644 --- a/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs @@ -112,7 +112,9 @@ public void GlobalStableCommandConfirmation_AlsoRequiresExplicitControlStatusRef Assert.Contains("return string.Empty;", resolver, StringComparison.Ordinal); Assert.Contains("NormalizeReference(signal.ControlStatusReference)", resolver, StringComparison.Ordinal); Assert.DoesNotContain("signal.ObjectReference", resolver, StringComparison.Ordinal); - Assert.DoesNotContain(".stVal", resolver, StringComparison.Ordinal); + Assert.DoesNotContain("SignalName)", resolver, StringComparison.Ordinal); + Assert.DoesNotContain("signal.ObjectReference +", resolver, StringComparison.Ordinal); + Assert.DoesNotContain("$\"{signal.ObjectReference}.stVal\"", resolver, StringComparison.Ordinal); } private static Iec61850MonitorDevice Device(string name) From a6e97d56e8549d11cd71a91dc8f6adf0c84ef0b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 17:47:41 +0700 Subject: [PATCH 087/158] fix: separate native FAT values from evidence timestamps --- MainWindow.NativeFatP4CColumnContract.cs | 87 +++++++++++++++++++++--- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/MainWindow.NativeFatP4CColumnContract.cs b/MainWindow.NativeFatP4CColumnContract.cs index 9253fd736..4b1eb8e89 100644 --- a/MainWindow.NativeFatP4CColumnContract.cs +++ b/MainWindow.NativeFatP4CColumnContract.cs @@ -1,3 +1,7 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; using ArIED61850Tester.Models; using ArIED61850Tester.Services.IoTesting; @@ -6,9 +10,10 @@ namespace ArIED61850Tester; public partial class MainWindow { /// - /// P4C makes FAT a thin view over the canonical IEC Explorer rows. - /// The grid keeps SelectedDevice.Points as its ItemsSource and exposes only the - /// exact Explorer-facing contract plus the three FAT evidence fields. + /// Native FAT is a thin view over the canonical IEC Explorer rows. The grid keeps + /// SelectedDevice.Points as its ItemsSource and adds only sparse evidence columns. + /// Values and timestamps are deliberately separate so the operator can scan evidence + /// without parsing a combined presentation string. /// private void ApplyNativeFatP4CColumnContract() { @@ -18,18 +23,78 @@ private void ApplyNativeFatP4CColumnContract() _nativeFatCanonicalGrid.Columns.Clear(); _nativeFatCanonicalGrid.FrozenColumnCount = 2; - AddCanonicalTextColumn("Signal", nameof(Iec61850MonitorPoint.SignalName), 220); - AddCanonicalTextColumn("IEC Telegram", nameof(Iec61850MonitorPoint.IecTelegram), 340); - AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 105); - AddCanonicalTemplateColumn("Live Value", "ProcessValueBadgeTemplate", 140); + AddCanonicalTextColumn("Signal", nameof(Iec61850MonitorPoint.SignalName), 190); + AddCanonicalTextColumn("IEC Telegram", nameof(Iec61850MonitorPoint.IecTelegram), 320); + AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 95); + AddCanonicalTemplateColumn("Live Value", "ProcessValueBadgeTemplate", 125); - // Value 1 / Value 2 deliberately remain row-bound evidence columns. - // Their displayed timestamp is formatted by the P4B structured evidence layer. _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceColumn(this, "Value 1", NativeFatEvidenceField.Value1, 225)); + new NativeFatEvidenceColumn(this, "Value 1", NativeFatEvidenceField.Value1, 120)); _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceColumn(this, "Value 2", NativeFatEvidenceField.Value2, 225)); + new NativeFatEvidenceTimestampColumn(this, "V1 Timestamp", NativeFatEvidenceField.Value1, 185)); + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceColumn(this, "Value 2", NativeFatEvidenceField.Value2, 120)); + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceTimestampColumn(this, "V2 Timestamp", NativeFatEvidenceField.Value2, 185)); _nativeFatCanonicalGrid.Columns.Add( new NativeFatEvidenceColumn(this, "Result", NativeFatEvidenceField.Result, 110)); } + + private string ReadNativeFatTimestamp(Iec61850MonitorPoint point, NativeFatEvidenceField field) + { + if (string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) || + !_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache)) + { + return string.Empty; + } + + var evidence = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, field); + if (evidence is null) + return string.Empty; + + var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; + return timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + + /// + /// Read-only timestamp companion to the editable Value 1 / Value 2 evidence columns. + /// It reads the same stable IEDName + IEC Telegram overlay and owns no row collection. + /// + private sealed class NativeFatEvidenceTimestampColumn : DataGridColumn + { + private readonly MainWindow _owner; + + internal NativeFatEvidenceTimestampColumn( + MainWindow owner, + string header, + NativeFatEvidenceField field, + double width) + { + _owner = owner; + Header = header; + Field = field; + Width = new DataGridLength(width); + MinWidth = 130; + IsReadOnly = true; + } + + internal NativeFatEvidenceField Field { get; } + + protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem) + { + return new TextBlock + { + Text = dataItem is Iec61850MonitorPoint point + ? _owner.ReadNativeFatTimestamp(point, Field) + : string.Empty, + FontFamily = new FontFamily("Consolas"), + FontSize = 11, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + } + + protected override FrameworkElement GenerateEditingElement(DataGridCell cell, object dataItem) + => GenerateElement(cell, dataItem); + } } From 26f040a2736ea40a20a7bc19b1a039634fe892ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 17:48:53 +0700 Subject: [PATCH 088/158] fix: split FAT values timestamps and complete result --- .../NativeFatCanonicalEvidenceOverlay.cs | 865 +++++++++--------- 1 file changed, 419 insertions(+), 446 deletions(-) diff --git a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs index 5b37a6bce..ba4bf2810 100644 --- a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs +++ b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs @@ -1,446 +1,419 @@ -using System.Globalization; -using ArIED61850Tester.Models; -using ArIED61850Tester.Models.IoTesting; - -namespace ArIED61850Tester.Services.IoTesting; - -public enum NativeFatEvidenceField -{ - Value1, - Value2, - Result -} - -/// -/// Sparse FAT-only evidence keyed by the stable IEC 61850 identity of the canonical -/// Engineering row: IEDName + IEC Telegram. DeviceId, row index, selected index and -/// display labels are deliberately excluded so evidence cannot jump to another signal -/// after reordering or recreation of the Engineering runtime device. -/// -public static class NativeFatCanonicalEvidenceOverlay -{ - public static string BuildRowKey(Iec61850MonitorPoint point) - { - ArgumentNullException.ThrowIfNull(point); - // P4A compatibility note: point.PointKey is intentionally not used here because - // it contains the runtime DeviceId and is not stable across Engineering recreation. - return TryBuildRowKey(point, out var rowKey) ? rowKey : string.Empty; - } - - public static bool TryBuildRowKey(Iec61850MonitorPoint point, out string rowKey) - { - ArgumentNullException.ThrowIfNull(point); - return TryBuildRowKey(point.DeviceName, point.IecTelegram, out rowKey); - } - - internal static bool TryBuildRowKey(string? iedName, string? iecTelegram, out string rowKey) - { - var normalizedIedName = NormalizeIedName(iedName); - var normalizedTelegram = NormalizeTelegram(iecTelegram); - if (normalizedIedName.Length == 0 || normalizedTelegram.Length == 0) - { - rowKey = string.Empty; - return false; - } - - rowKey = $"{normalizedIedName}|{normalizedTelegram}"; - return true; - } - - /// - /// Default/operator-facing read used by the native FAT grid. Value 1 / Value 2 include - /// the evidence timestamp while Result remains plain text. - /// - public static string Read( - NativeFatIedSessionCacheState cache, - Iec61850MonitorPoint point, - NativeFatEvidenceField field) - => ReadDisplay(cache, point, field); - - /// - /// Raw evidence value used for semantic comparison, persistence tests and report adapters - /// that carry timestamp metadata separately. - /// - public static string ReadRaw( - NativeFatIedSessionCacheState cache, - Iec61850MonitorPoint point, - NativeFatEvidenceField field) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(point); - - if (!TryBuildRowKey(point, out var key)) - return string.Empty; - - lock (cache.EvidenceByRow) - { - if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) - return string.Empty; - - return field switch - { - NativeFatEvidenceField.Value1 => RawValue(slot.Value1Evidence, slot.Value1), - NativeFatEvidenceField.Value2 => RawValue(slot.Value2Evidence, slot.Value2), - NativeFatEvidenceField.Result => slot.Result, - _ => string.Empty - }; - } - } - - /// - /// Operator-facing evidence text. P4B deliberately keeps timestamp out of the raw value - /// so comparisons remain type-safe while the grid/report can show "value - timestamp". - /// IED time is preferred; ARSAS capture time is the explicit fallback. - /// - public static string ReadDisplay( - NativeFatIedSessionCacheState cache, - Iec61850MonitorPoint point, - NativeFatEvidenceField field) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(point); - - if (!TryBuildRowKey(point, out var key)) - return string.Empty; - - lock (cache.EvidenceByRow) - { - if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) - return string.Empty; - - return field switch - { - NativeFatEvidenceField.Value1 => DisplayValue(slot.Value1Evidence, slot.Value1), - NativeFatEvidenceField.Value2 => DisplayValue(slot.Value2Evidence, slot.Value2), - NativeFatEvidenceField.Result => slot.Result, - _ => string.Empty - }; - } - } - - public static FatValueEvidence? ReadCapture( - NativeFatIedSessionCacheState cache, - Iec61850MonitorPoint point, - NativeFatEvidenceField field) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(point); - - if (!TryBuildRowKey(point, out var key)) - return null; - - lock (cache.EvidenceByRow) - { - if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) - return null; - - return field switch - { - NativeFatEvidenceField.Value1 => slot.Value1Evidence, - NativeFatEvidenceField.Value2 => slot.Value2Evidence, - _ => null - }; - } - } - - /// - /// Compatibility/operator write. Value slots still become structured evidence, using - /// the current point metadata and ARSAS time when there is no separate acquisition event. - /// - public static void Write( - NativeFatIedSessionCacheState cache, - Iec61850MonitorPoint point, - NativeFatEvidenceField field, - string? value) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(point); - - if (!TryBuildRowKey(point, out var key)) - return; - - var supplied = value ?? string.Empty; - if ((field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) && - string.Equals( - ReadDisplay(cache, point, field).Trim(), - supplied.Trim(), - StringComparison.Ordinal)) - { - // WPF editing starts from the rendered "value - timestamp" text. Committing an - // untouched cell must preserve the original evidence metadata, not recapture it. - return; - } - - var text = (field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) - ? StripDisplayTimestamp(supplied) - : supplied; - if ((field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) && - !string.IsNullOrWhiteSpace(text)) - { - WriteCapture( - cache, - point, - field, - text, - FatEvidenceCaptureKind.OperatorRecapture, - DateTimeOffset.Now); - return; - } - - lock (cache.EvidenceByRow) - { - if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) - { - if (string.IsNullOrWhiteSpace(text)) - return; - - slot = new NativeFatEvidenceSlotState(); - cache.EvidenceByRow[key] = slot; - } - - switch (field) - { - case NativeFatEvidenceField.Value1: - slot.Value1 = text; - slot.Value1Evidence = null; - break; - case NativeFatEvidenceField.Value2: - slot.Value2 = text; - slot.Value2Evidence = null; - break; - case NativeFatEvidenceField.Result: - slot.Result = text; - break; - } - - RemoveIfEmpty(cache, key, slot); - } - } - - public static void WriteCapture( - NativeFatIedSessionCacheState cache, - Iec61850MonitorPoint point, - NativeFatEvidenceField field, - string rawValue, - FatEvidenceCaptureKind captureKind, - DateTimeOffset capturedAt) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(point); - if (field is not (NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2)) - throw new ArgumentOutOfRangeException(nameof(field), field, "Only Value 1 / Value 2 are structured captures."); - if (!TryBuildRowKey(point, out var key) || string.IsNullOrWhiteSpace(rawValue)) - return; - - var slotKind = field == NativeFatEvidenceField.Value1 ? FatValueSlot.Value1 : FatValueSlot.Value2; - var evidence = new FatValueEvidence( - Guid.NewGuid(), - slotKind, - captureKind, - rawValue.Trim(), - capturedAt, - IoTestValueNormalizer.ParseIedTimestamp(point.DeviceTimestamp), - string.IsNullOrWhiteSpace(point.Quality) ? "Unknown" : point.Quality.Trim(), - string.IsNullOrWhiteSpace(point.SourceMode) ? "Engineering live" : point.SourceMode.Trim(), - point.Sequence, - -1); - - lock (cache.EvidenceByRow) - { - if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) - { - slot = new NativeFatEvidenceSlotState(); - cache.EvidenceByRow[key] = slot; - } - - if (field == NativeFatEvidenceField.Value1) - { - slot.Value1 = evidence.RawValue; - slot.Value1Evidence = evidence; - } - else - { - slot.Value2 = evidence.RawValue; - slot.Value2Evidence = evidence; - } - } - } - - /// - /// Rolls the latest Value 2 observation into Value 1 without losing its original - /// relay/ARSAS timestamp or source metadata. - /// - public static bool PromoteValue2ToValue1( - NativeFatIedSessionCacheState cache, - Iec61850MonitorPoint point) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(point); - if (!TryBuildRowKey(point, out var key)) - return false; - - lock (cache.EvidenceByRow) - { - if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) - return false; - - var raw = RawValue(slot.Value2Evidence, slot.Value2); - if (string.IsNullOrWhiteSpace(raw)) - return false; - - slot.Value1 = raw; - slot.Value1Evidence = slot.Value2Evidence is null - ? null - : slot.Value2Evidence with { Slot = FatValueSlot.Value1 }; - return true; - } - } - - /// - /// Persisted evidence may fill missing cells but may never overwrite evidence captured - /// after hydration started. Structured metadata moves with its raw value atomically. - /// - public static int MergeMissing( - NativeFatIedSessionCacheState cache, - IReadOnlyDictionary hydratedEvidence) - { - ArgumentNullException.ThrowIfNull(cache); - ArgumentNullException.ThrowIfNull(hydratedEvidence); - - var mergedRows = 0; - lock (cache.EvidenceByRow) - { - foreach (var pair in hydratedEvidence) - { - if (string.IsNullOrWhiteSpace(pair.Key)) - continue; - - var incoming = pair.Value; - if (incoming == null || IsEmpty(incoming)) - continue; - - if (!cache.EvidenceByRow.TryGetValue(pair.Key, out var current)) - { - cache.EvidenceByRow[pair.Key] = Clone(incoming); - mergedRows++; - continue; - } - - var changed = false; - if (!HasValue1(current) && HasValue1(incoming)) - { - current.Value1 = RawValue(incoming.Value1Evidence, incoming.Value1); - current.Value1Evidence = incoming.Value1Evidence; - changed = true; - } - if (!HasValue2(current) && HasValue2(incoming)) - { - current.Value2 = RawValue(incoming.Value2Evidence, incoming.Value2); - current.Value2Evidence = incoming.Value2Evidence; - changed = true; - } - if (string.IsNullOrWhiteSpace(current.Result) && !string.IsNullOrWhiteSpace(incoming.Result)) - { - current.Result = incoming.Result; - changed = true; - } - - if (changed) - mergedRows++; - } - } - - return mergedRows; - } - - public static IReadOnlyDictionary Snapshot( - NativeFatIedSessionCacheState cache) - { - ArgumentNullException.ThrowIfNull(cache); - lock (cache.EvidenceByRow) - { - return cache.EvidenceByRow.ToDictionary( - pair => pair.Key, - pair => Clone(pair.Value), - StringComparer.OrdinalIgnoreCase); - } - } - - internal static string NormalizeIedName(string? iedName) - => (iedName ?? string.Empty).Trim().ToLowerInvariant(); - - internal static string NormalizeTelegram(string? iecTelegram) - { - var text = (iecTelegram ?? string.Empty) - .Trim() - .Replace('$', '.') - .Replace("..", ".", StringComparison.Ordinal) - .ToLowerInvariant(); - while (text.Contains("..", StringComparison.Ordinal)) - text = text.Replace("..", ".", StringComparison.Ordinal); - return text.Trim('.'); - } - - private static string StripDisplayTimestamp(string value) - { - var text = value?.Trim() ?? string.Empty; - var separator = text.LastIndexOf(" - ", StringComparison.Ordinal); - if (separator <= 0) - return text; - - var suffix = text[(separator + 3)..]; - return DateTime.TryParseExact( - suffix, - "yyyy-MM-dd HH:mm:ss.fff", - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out _) - ? text[..separator].Trim() - : text; - } - - private static string DisplayValue(FatValueEvidence? evidence, string legacyRaw) - { - var raw = RawValue(evidence, legacyRaw); - if (string.IsNullOrWhiteSpace(raw)) - return string.Empty; - if (evidence is null) - return raw; - - var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; - return $"{raw} - {timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)}"; - } - - private static string RawValue(FatValueEvidence? evidence, string legacyRaw) - => !string.IsNullOrWhiteSpace(evidence?.RawValue) - ? evidence.RawValue.Trim() - : legacyRaw?.Trim() ?? string.Empty; - - private static bool HasValue1(NativeFatEvidenceSlotState slot) - => !string.IsNullOrWhiteSpace(RawValue(slot.Value1Evidence, slot.Value1)); - - private static bool HasValue2(NativeFatEvidenceSlotState slot) - => !string.IsNullOrWhiteSpace(RawValue(slot.Value2Evidence, slot.Value2)); - - private static bool IsEmpty(NativeFatEvidenceSlotState slot) - => !HasValue1(slot) && !HasValue2(slot) && string.IsNullOrWhiteSpace(slot.Result); - - private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) - => new() - { - Value1 = RawValue(source.Value1Evidence, source.Value1), - Value2 = RawValue(source.Value2Evidence, source.Value2), - Value1Evidence = source.Value1Evidence, - Value2Evidence = source.Value2Evidence, - Result = source.Result - }; - - private static void RemoveIfEmpty( - NativeFatIedSessionCacheState cache, - string key, - NativeFatEvidenceSlotState slot) - { - if (IsEmpty(slot)) - cache.EvidenceByRow.Remove(key); - } -} +using System.Globalization; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public enum NativeFatEvidenceField +{ + Value1, + Value2, + Result +} + +/// +/// Sparse FAT-only evidence keyed exclusively by stable IEC identity: IEDName + IEC Telegram. +/// Runtime DeviceId, row index, SelectedIndex and display labels are never evidence identity. +/// +public static class NativeFatCanonicalEvidenceOverlay +{ + public static string BuildRowKey(Iec61850MonitorPoint point) + { + ArgumentNullException.ThrowIfNull(point); + return TryBuildRowKey(point, out var rowKey) ? rowKey : string.Empty; + } + + public static bool TryBuildRowKey(Iec61850MonitorPoint point, out string rowKey) + { + ArgumentNullException.ThrowIfNull(point); + return TryBuildRowKey(point.DeviceName, point.IecTelegram, out rowKey); + } + + internal static bool TryBuildRowKey(string? iedName, string? iecTelegram, out string rowKey) + { + var normalizedIedName = NormalizeIedName(iedName); + var normalizedTelegram = NormalizeTelegram(iecTelegram); + if (normalizedIedName.Length == 0 || normalizedTelegram.Length == 0) + { + rowKey = string.Empty; + return false; + } + + rowKey = $"{normalizedIedName}|{normalizedTelegram}"; + return true; + } + + public static string Read( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return string.Empty; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => RawValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value2 => RawValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Result => ResolveResult(slot), + _ => string.Empty + }; + } + } + + public static string ReadRaw( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return string.Empty; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => RawValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value2 => RawValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Result => slot.Result, + _ => string.Empty + }; + } + } + + /// + /// Compatibility display form retained for legacy/manual consumers. Native FAT no longer + /// uses this combined text because values and timestamps have dedicated columns. + /// + public static string ReadDisplay( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return string.Empty; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => DisplayValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value2 => DisplayValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Result => ResolveResult(slot), + _ => string.Empty + }; + } + } + + public static FatValueEvidence? ReadCapture( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return null; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return null; + + return field switch + { + NativeFatEvidenceField.Value1 => slot.Value1Evidence, + NativeFatEvidenceField.Value2 => slot.Value2Evidence, + _ => null + }; + } + } + + public static void Write( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field, + string? value) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return; + + var supplied = value ?? string.Empty; + if ((field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) && + string.Equals(ReadRaw(cache, point, field).Trim(), supplied.Trim(), StringComparison.Ordinal)) + { + return; + } + + var text = (field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) + ? StripDisplayTimestamp(supplied) + : supplied.Trim(); + + if ((field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) && + !string.IsNullOrWhiteSpace(text)) + { + WriteCapture(cache, point, field, text, FatEvidenceCaptureKind.OperatorRecapture, DateTimeOffset.Now); + return; + } + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + { + if (string.IsNullOrWhiteSpace(text)) + return; + slot = new NativeFatEvidenceSlotState(); + cache.EvidenceByRow[key] = slot; + } + + switch (field) + { + case NativeFatEvidenceField.Value1: + slot.Value1 = text; + slot.Value1Evidence = null; + break; + case NativeFatEvidenceField.Value2: + slot.Value2 = text; + slot.Value2Evidence = null; + break; + case NativeFatEvidenceField.Result: + slot.Result = text; + break; + } + + RemoveIfEmpty(cache, key, slot); + } + } + + public static void WriteCapture( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field, + string rawValue, + FatEvidenceCaptureKind captureKind, + DateTimeOffset capturedAt) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (field is not (NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2)) + throw new ArgumentOutOfRangeException(nameof(field), field, "Only Value 1 / Value 2 are structured captures."); + if (!TryBuildRowKey(point, out var key) || string.IsNullOrWhiteSpace(rawValue)) + return; + + var slotKind = field == NativeFatEvidenceField.Value1 ? FatValueSlot.Value1 : FatValueSlot.Value2; + var evidence = new FatValueEvidence( + Guid.NewGuid(), + slotKind, + captureKind, + rawValue.Trim(), + capturedAt, + IoTestValueNormalizer.ParseIedTimestamp(point.DeviceTimestamp), + string.IsNullOrWhiteSpace(point.Quality) ? "Unknown" : point.Quality.Trim(), + string.IsNullOrWhiteSpace(point.SourceMode) ? "Engineering live" : point.SourceMode.Trim(), + point.Sequence, + -1); + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + { + slot = new NativeFatEvidenceSlotState(); + cache.EvidenceByRow[key] = slot; + } + + if (field == NativeFatEvidenceField.Value1) + { + slot.Value1 = evidence.RawValue; + slot.Value1Evidence = evidence; + } + else + { + slot.Value2 = evidence.RawValue; + slot.Value2Evidence = evidence; + } + } + } + + public static bool PromoteValue2ToValue1(NativeFatIedSessionCacheState cache, Iec61850MonitorPoint point) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return false; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return false; + + var raw = RawValue(slot.Value2Evidence, slot.Value2); + if (string.IsNullOrWhiteSpace(raw)) + return false; + + slot.Value1 = raw; + slot.Value1Evidence = slot.Value2Evidence is null ? null : slot.Value2Evidence with { Slot = FatValueSlot.Value1 }; + return true; + } + } + + public static int MergeMissing( + NativeFatIedSessionCacheState cache, + IReadOnlyDictionary hydratedEvidence) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(hydratedEvidence); + var mergedRows = 0; + + lock (cache.EvidenceByRow) + { + foreach (var pair in hydratedEvidence) + { + if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null || IsEmpty(pair.Value)) + continue; + + var incoming = pair.Value; + if (!cache.EvidenceByRow.TryGetValue(pair.Key, out var current)) + { + cache.EvidenceByRow[pair.Key] = Clone(incoming); + mergedRows++; + continue; + } + + var changed = false; + if (!HasValue1(current) && HasValue1(incoming)) + { + current.Value1 = RawValue(incoming.Value1Evidence, incoming.Value1); + current.Value1Evidence = incoming.Value1Evidence; + changed = true; + } + if (!HasValue2(current) && HasValue2(incoming)) + { + current.Value2 = RawValue(incoming.Value2Evidence, incoming.Value2); + current.Value2Evidence = incoming.Value2Evidence; + changed = true; + } + if (string.IsNullOrWhiteSpace(current.Result) && !string.IsNullOrWhiteSpace(incoming.Result)) + { + current.Result = incoming.Result; + changed = true; + } + if (changed) + mergedRows++; + } + } + + return mergedRows; + } + + public static IReadOnlyDictionary Snapshot(NativeFatIedSessionCacheState cache) + { + ArgumentNullException.ThrowIfNull(cache); + lock (cache.EvidenceByRow) + { + return cache.EvidenceByRow.ToDictionary( + pair => pair.Key, + pair => Clone(pair.Value), + StringComparer.OrdinalIgnoreCase); + } + } + + internal static string NormalizeIedName(string? iedName) + => (iedName ?? string.Empty).Trim().ToLowerInvariant(); + + internal static string NormalizeTelegram(string? iecTelegram) + { + var text = (iecTelegram ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".", StringComparison.Ordinal) + .ToLowerInvariant(); + while (text.Contains("..", StringComparison.Ordinal)) + text = text.Replace("..", ".", StringComparison.Ordinal); + return text.Trim('.'); + } + + private static string ResolveResult(NativeFatEvidenceSlotState slot) + { + if (!string.IsNullOrWhiteSpace(slot.Result)) + return slot.Result.Trim(); + return HasValue1(slot) && HasValue2(slot) ? "COMPLETE" : string.Empty; + } + + private static string StripDisplayTimestamp(string value) + { + var text = value?.Trim() ?? string.Empty; + var separator = text.LastIndexOf(" - ", StringComparison.Ordinal); + if (separator <= 0) + return text; + + var suffix = text[(separator + 3)..]; + return DateTime.TryParseExact( + suffix, + "yyyy-MM-dd HH:mm:ss.fff", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _) + ? text[..separator].Trim() + : text; + } + + private static string DisplayValue(FatValueEvidence? evidence, string legacyRaw) + { + var raw = RawValue(evidence, legacyRaw); + if (string.IsNullOrWhiteSpace(raw)) + return string.Empty; + if (evidence is null) + return raw; + var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; + return $"{raw} - {timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)}"; + } + + private static string RawValue(FatValueEvidence? evidence, string legacyRaw) + => !string.IsNullOrWhiteSpace(evidence?.RawValue) + ? evidence.RawValue.Trim() + : legacyRaw?.Trim() ?? string.Empty; + + private static bool HasValue1(NativeFatEvidenceSlotState slot) + => !string.IsNullOrWhiteSpace(RawValue(slot.Value1Evidence, slot.Value1)); + + private static bool HasValue2(NativeFatEvidenceSlotState slot) + => !string.IsNullOrWhiteSpace(RawValue(slot.Value2Evidence, slot.Value2)); + + private static bool IsEmpty(NativeFatEvidenceSlotState slot) + => !HasValue1(slot) && !HasValue2(slot) && string.IsNullOrWhiteSpace(slot.Result); + + private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) + => new() + { + Value1 = RawValue(source.Value1Evidence, source.Value1), + Value2 = RawValue(source.Value2Evidence, source.Value2), + Value1Evidence = source.Value1Evidence, + Value2Evidence = source.Value2Evidence, + Result = source.Result + }; + + private static void RemoveIfEmpty(NativeFatIedSessionCacheState cache, string key, NativeFatEvidenceSlotState slot) + { + if (IsEmpty(slot)) + cache.EvidenceByRow.Remove(key); + } +} From 4c5765a113e8489cc710f512f15dc5d7140c81dd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 17:49:16 +0700 Subject: [PATCH 089/158] fix: snapshot exact FAT signal and timestamp columns --- .../NativeFatPrintPreviewSnapshot.cs | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index 1d539599e..ca26e9eed 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Globalization; using ArIED61850Tester.Models; namespace ArIED61850Tester.Services.IoTesting; @@ -9,13 +10,15 @@ public sealed record NativeFatPrintPreviewRow( string Quality, string LiveValue, string Value1, + string Value1Timestamp, string Value2, + string Value2Timestamp, string Result); /// /// Immutable selected-IED-only report input for native Engineering FAT. -/// Capture copies the exact P4C visible contract from canonical Engineering rows plus -/// sparse evidence overlay. No live row/evidence object is retained after Capture returns. +/// Capture copies the exact native FAT row order plus sparse evidence overlay. No live +/// row/evidence object is retained after Capture returns and acquisition is never restarted. /// public sealed class NativeFatPrintPreviewSnapshot { @@ -53,32 +56,23 @@ public static NativeFatPrintPreviewSnapshot Capture( ArgumentNullException.ThrowIfNull(device); ArgumentNullException.ThrowIfNull(cache); - // Materialize in the current canonical Engineering row order. Every value below - // is copied now; P4D never binds back to device.Points or EvidenceByRow. - // Value 1/2 deliberately use the exact operator-facing P4B display text so the - // relay/ARSAS timestamp visible in FAT is preserved in Print Preview and PDF. var rows = device.Points.Select(point => { - var value1 = NativeFatCanonicalEvidenceOverlay.ReadDisplay( - cache, - point, - NativeFatEvidenceField.Value1).Trim(); - var value2 = NativeFatCanonicalEvidenceOverlay.ReadDisplay( - cache, - point, - NativeFatEvidenceField.Value2).Trim(); - var result = NativeFatCanonicalEvidenceOverlay.ReadRaw( - cache, - point, - NativeFatEvidenceField.Result).Trim(); + var value1 = NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1); + var value2 = NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2); + var capture1 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); + var capture2 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value2); + var result = NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result); return new NativeFatPrintPreviewRow( - Copy(point.SignalName), + point.SignalName ?? string.Empty, Copy(point.IecTelegram), Copy(point.Quality), Display(point.DisplayValue), Display(value1), + DisplayTimestamp(capture1), Display(value2), + DisplayTimestamp(capture2), Display(result)); }).ToArray(); @@ -91,6 +85,14 @@ public static NativeFatPrintPreviewSnapshot Capture( rows); } + private static string DisplayTimestamp(FatValueEvidence? evidence) + { + if (evidence is null) + return "—"; + var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; + return timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + private static bool HasEvidence(string? value) => !string.IsNullOrWhiteSpace(value) && value.Trim() != "—"; From 34b0d32e823b5b44634b0807afbb803d09468842 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 20:29:17 +0700 Subject: [PATCH 090/158] fix(fat): align report with nine-column evidence grid --- .../IoTesting/NativeFatP4DReportAdapter.cs | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs index 3ee1c7805..b08ed948a 100644 --- a/Services/IoTesting/NativeFatP4DReportAdapter.cs +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -15,12 +15,14 @@ internal static class NativeFatP4DReportAdapter private const double ContentBottom = 52d; private const double HeaderHeight = 24d; private const double MinimumRowHeight = 30d; - private const int TelegramCharsPerLine = 46; + private const int TelegramCharsPerLine = 38; // Exact P4C visible contract. Total width = 782 pt (842 - 2 * 30 margin). - private static readonly double[] Widths = [110d, 245d, 80d, 85d, 85d, 85d, 92d]; + // Timestamp columns are intentionally first-class columns so the immutable report mirrors + // the FAT grid rather than collapsing observation metadata into Value 1 / Value 2 text. + private static readonly double[] Widths = [90d, 200d, 65d, 70d, 62d, 90d, 62d, 90d, 53d]; private static readonly string[] Headers = - ["Signal", "IEC Telegram", "Quality", "Live Value", "Value 1", "Value 2", "Result"]; + ["Signal", "IEC Telegram", "Quality", "Live Value", "Value 1", "V1 Timestamp", "Value 2", "V2 Timestamp", "Result"]; private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); @@ -158,12 +160,12 @@ private static void DrawTableHeader(List page, ref double y) { page.Add(new IoFatReportRectCommand(x, y, Widths[index], HeaderHeight, 0d, SoftBlue, Border, 0.45d)); page.Add(new IoFatReportTextCommand( - x + 5d, + x + 4d, y - 15.5d, - Widths[index] - 10d, + Widths[index] - 8d, Headers[index], IoFatReportFontKind.Bold, - 6.2d, + index is 5 or 7 ? 5.25d : 5.8d, Blue)); x += Widths[index]; } @@ -186,7 +188,9 @@ private static void DrawRow( Clean(row.Quality), Clean(row.LiveValue), Clean(row.Value1), + Clean(row.Value1TimestampText), Clean(row.Value2), + Clean(row.Value2TimestampText), Clean(row.Result) }; @@ -201,26 +205,29 @@ private static void DrawRow( foreach (var line in WrapTelegram(row.IecTelegram)) { page.Add(new IoFatReportTextCommand( - x + 5d, + x + 4d, lineY, - Widths[index] - 10d, + Widths[index] - 8d, line, IoFatReportFontKind.Mono, - 5.6d, + 5.35d, Ink)); lineY -= 8.6d; } } else { + var isTimestamp = index is 5 or 7; page.Add(new IoFatReportTextCommand( - x + 5d, + x + 4d, y - 18d, - Widths[index] - 10d, + Widths[index] - 8d, cells[index], - index is 0 or 6 ? IoFatReportFontKind.Bold : IoFatReportFontKind.Regular, - 6.2d, - index == 6 ? ResultColor(row.Result) : Ink)); + isTimestamp + ? IoFatReportFontKind.Mono + : index is 0 or 8 ? IoFatReportFontKind.Bold : IoFatReportFontKind.Regular, + isTimestamp ? 4.9d : 5.8d, + index == 8 ? ResultColor(row.Result) : Ink)); } x += Widths[index]; From 1038e5387d0bed348ce772b3805794d07dbad228 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:04:46 +0700 Subject: [PATCH 091/158] fix(fat): restore native report signoff page --- .../IoTesting/NativeFatReportFinalization.cs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 Services/IoTesting/NativeFatReportFinalization.cs diff --git a/Services/IoTesting/NativeFatReportFinalization.cs b/Services/IoTesting/NativeFatReportFinalization.cs new file mode 100644 index 000000000..94025317f --- /dev/null +++ b/Services/IoTesting/NativeFatReportFinalization.cs @@ -0,0 +1,157 @@ +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Finalizes the immutable native FAT layout without rebuilding an IoTestProject. +/// The sign-off page is intentionally blank evidence: it provides the controlled +/// TESTED BY / WITNESSED BY / APPROVED BY acceptance fields and never invents names, +/// signatures, dates, COMTRADE records, or time-sync evidence. +/// +internal static class NativeFatReportFinalization +{ + private const double PageWidth = 842d; + private const double PageHeight = 595d; + private const double Margin = 30d; + private const double ContentWidth = PageWidth - (Margin * 2d); + + private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); + private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); + private static readonly IoFatReportColor SoftBlue = IoFatReportColor.FromHex("EFF6FF"); + private static readonly IoFatReportColor Border = IoFatReportColor.FromHex("D9E4F0"); + private static readonly IoFatReportColor Muted = IoFatReportColor.FromHex("64748B"); + private static readonly IoFatReportColor Ink = IoFatReportColor.FromHex("1F2937"); + private static readonly IoFatReportColor White = IoFatReportColor.FromHex("FFFFFF"); + + public static IoFatReportLayoutPlan AppendSignOff( + IoFatReportLayoutPlan baseLayout, + NativeFatPrintPreviewSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(baseLayout); + ArgumentNullException.ThrowIfNull(snapshot); + + var totalPages = baseLayout.Pages.Count + 1; + var pages = new List(totalPages); + + for (var index = 0; index < baseLayout.Pages.Count; index++) + { + var corrected = baseLayout.Pages[index].Commands + .Select(command => CorrectPageTotal(command, index + 1, totalPages)) + .ToArray(); + pages.Add(new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, corrected)); + } + + pages.Add(BuildSignOffPage(snapshot, totalPages, totalPages, baseLayout.CreatedAt)); + return new IoFatReportLayoutPlan(baseLayout.ProjectId, baseLayout.CreatedAt, baseLayout.Draft, pages); + } + + private static IoFatReportPagePlan BuildSignOffPage( + NativeFatPrintPreviewSnapshot snapshot, + int pageNumber, + int totalPages, + DateTimeOffset createdAt) + { + var commands = new List(); + + Text(commands, Margin, 566d, 490d, "ARSAS | IEC 61850 FAT", IoFatReportFontKind.Bold, 7.2d, Muted); + Text(commands, Margin, 544d, 520d, "FAT Acceptance Sign-Off", IoFatReportFontKind.Bold, 17.2d, Navy); + Text(commands, Margin, 522d, 540d, + "Final acceptance record for the immutable IEC 61850 FAT evidence contained in this report.", + IoFatReportFontKind.Regular, 8.0d, Muted); + Line(commands, Margin, 498d, PageWidth - Margin, 498d, Border, 0.8d); + + Rect(commands, 590d, 568d, 222d, 64d, 4d, SoftBlue, Border, 0.7d); + Text(commands, 601d, 554d, 200d, "IED / REPORT SCOPE", IoFatReportFontKind.Bold, 5.9d, Muted); + Text(commands, 601d, 538d, 200d, Clean(snapshot.IedName), IoFatReportFontKind.Bold, 8.2d, Navy); + Text(commands, 601d, 523d, 200d, Clean(snapshot.DeviceId), IoFatReportFontKind.Mono, 5.8d, Blue); + Text(commands, 601d, 511d, 200d, "FOR FAT RECORD", IoFatReportFontKind.Regular, 5.8d, Muted); + + Text(commands, Margin, 470d, ContentWidth, + "By signing below, the parties acknowledge the FAT execution and evidence recorded in the preceding pages.", + IoFatReportFontKind.Regular, 7.2d, Ink); + + const double gap = 14d; + var boxWidth = (ContentWidth - (gap * 2d)) / 3d; + var x = Margin; + foreach (var heading in new[] { "TESTED BY", "WITNESSED BY", "APPROVED BY" }) + { + DrawSignOffBox(commands, x, 430d, boxWidth, 286d, heading); + x += boxWidth + gap; + } + + Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); + Text(commands, Margin, 24d, 620d, + $"Immutable FAT snapshot · {createdAt:yyyy-MM-dd HH:mm:ss zzz} · blank sign-off fields are intentionally not prefilled", + IoFatReportFontKind.Regular, 6.2d, Muted); + Text(commands, PageWidth - Margin - 118d, 24d, 118d, + $"Page {pageNumber} / {totalPages}", + IoFatReportFontKind.Regular, 6.2d, Muted); + + return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); + } + + private static void DrawSignOffBox( + ICollection commands, + double x, + double top, + double width, + double height, + string heading) + { + Rect(commands, x, top, width, height, 4d, White, Border, 0.8d); + Rect(commands, x, top, width, 34d, 4d, SoftBlue, Border, 0.6d); + Text(commands, x + 12d, top - 21d, width - 24d, heading, IoFatReportFontKind.Bold, 8.3d, Navy); + + var lineX = x + 12d; + var lineRight = x + width - 12d; + Text(commands, lineX, top - 63d, width - 24d, "Name", IoFatReportFontKind.Bold, 6.1d, Muted); + Line(commands, lineX, top - 92d, lineRight, top - 92d, Border, 0.65d); + Text(commands, lineX, top - 115d, width - 24d, "Company / Organization", IoFatReportFontKind.Bold, 6.1d, Muted); + Line(commands, lineX, top - 144d, lineRight, top - 144d, Border, 0.65d); + Text(commands, lineX, top - 168d, width - 24d, "Signature", IoFatReportFontKind.Bold, 6.1d, Muted); + Rect(commands, lineX, top - 183d, width - 24d, 54d, 0d, White, Border, 0.55d); + Text(commands, lineX, top - 255d, width - 24d, "Date", IoFatReportFontKind.Bold, 6.1d, Muted); + Line(commands, lineX, top - 275d, lineRight, top - 275d, Border, 0.65d); + } + + private static IoFatReportCommand CorrectPageTotal(IoFatReportCommand command, int pageNumber, int totalPages) + { + if (command is IoFatReportTextCommand text && text.Text.StartsWith("Page ", StringComparison.Ordinal)) + return text with { Text = $"Page {pageNumber} / {totalPages}" }; + return command; + } + + private static void Rect( + ICollection commands, + double x, + double top, + double width, + double height, + double radius, + IoFatReportColor fill, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportRectCommand(x, top, width, height, radius, fill, stroke, strokeThickness)); + + private static void Line( + ICollection commands, + double x1, + double y1, + double x2, + double y2, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, strokeThickness)); + + private static void Text( + ICollection commands, + double x, + double baselineY, + double width, + string text, + IoFatReportFontKind font, + double fontSize, + IoFatReportColor color) + => commands.Add(new IoFatReportTextCommand(x, baselineY, width, text, font, fontSize, color)); + + private static string Clean(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); +} From f905dfdae4ec978f92e67c4578bed488e515f88e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:05:14 +0700 Subject: [PATCH 092/158] fix(fat): finalize native report with signoff --- Services/IoTesting/NativeFatP4DReportAdapter.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs index b08ed948a..dc6519f9f 100644 --- a/Services/IoTesting/NativeFatP4DReportAdapter.cs +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -90,12 +90,16 @@ public static IoFatReportLayoutPlan Build(NativeFatPrintPreviewSnapshot snapshot Muted)); } - return new IoFatReportLayoutPlan( + var baseLayout = new IoFatReportLayoutPlan( snapshot.DeviceId, snapshot.CapturedAt, draft, pages.Select((commands, index) => new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, commands.ToArray())).ToArray()); + + // Final acceptance sign-off is part of the exact immutable layout shared by preview + // and Save PDF. Fields stay blank by design; no operator identity/evidence is invented. + return NativeFatReportFinalization.AppendSignOff(baseLayout, snapshot); } private static List NewPage( From d7801ad8fb1a0a2c950906b106f39680106c09a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:05:56 +0700 Subject: [PATCH 093/158] fix(fat): restore professional native print preview --- MainWindow.NativeFatPrintPreview.cs | 283 ++++++++++++++++++++++++---- 1 file changed, 247 insertions(+), 36 deletions(-) diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs index a7a7e9c0f..56c86c2e1 100644 --- a/MainWindow.NativeFatPrintPreview.cs +++ b/MainWindow.NativeFatPrintPreview.cs @@ -1,6 +1,10 @@ using System.Windows; using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; using System.Windows.Media; +using System.Windows.Shapes; +using System.Windows.Threading; using ArIED61850Tester.Services.IoTesting; using Microsoft.Win32; @@ -11,6 +15,19 @@ public partial class MainWindow private const string NativeFatPrintPreviewTitle = "IEC 61850 FAT Evidence Report"; private Button? _nativeFatPrintPreviewButton; + private enum NativePreviewLucideIcon + { + Printer, + Minus, + Plus, + Maximize2, + ChevronLeft, + ChevronRight, + RefreshCw, + Save, + X + } + private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) { var device = SelectedDevice; @@ -20,12 +37,7 @@ private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) return; } - // Commit the current operator evidence cell before copying the report snapshot. - // Capture is intentionally invoked only from this click path: normal FAT navigation, - // row binding, hydration, and acquisition never build a hidden report. - _nativeFatCanonicalGrid?.CommitEdit(DataGridEditingUnit.Cell, true); - _nativeFatCanonicalGrid?.CommitEdit(DataGridEditingUnit.Row, true); - + CommitNativeFatEvidenceEdits(); var snapshot = NativeFatPrintPreviewSnapshot.Capture( device, GetNativeFatSession(device.DeviceId)); @@ -35,26 +47,27 @@ private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) } /// - /// P4D preview path: immutable selected-IED snapshot -> thin report layout adapter -> - /// existing FixedDocument renderer -> DocumentViewer. Preview and Save PDF consume the - /// exact same IoFatReportLayoutPlan instance. No live row, evidence cache, SCL import, - /// discovery, reconnect, or second acquisition engine is retained here. + /// Professional native preview: immutable selected-IED snapshot -> shared layout adapter -> + /// existing FixedDocument renderer. The stock DocumentViewer toolbar is hidden and the + /// ARSAS/Lucide-style toolbar owns print, zoom, fit, page navigation, refresh and Save PDF. + /// Preview and Save PDF always consume the exact same IoFatReportLayoutPlan instance. /// private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) { ArgumentNullException.ThrowIfNull(snapshot); - var layout = NativeFatP4DReportAdapter.Build(snapshot, draft: true); - var document = IoFatReportPreviewDocumentBuilder.Render(layout); + var currentSnapshot = snapshot; + var currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true); + var document = IoFatReportPreviewDocumentBuilder.Render(currentLayout); var preview = new Window { Owner = this, Title = NativeFatPrintPreviewTitle, - Width = 1220, - Height = 860, - MinWidth = 920, - MinHeight = 640, + Width = 1260, + Height = 880, + MinWidth = 960, + MinHeight = 660, WindowStartupLocation = WindowStartupLocation.CenterOwner, Background = new SolidColorBrush(Color.FromRgb(232, 237, 244)) }; @@ -65,7 +78,7 @@ private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) var toolbar = new Border { - Padding = new Thickness(14, 10, 14, 10), + Padding = new Thickness(14, 9, 14, 9), Background = Brushes.White, BorderBrush = new SolidColorBrush(Color.FromRgb(216, 224, 234)), BorderThickness = new Thickness(0, 0, 0, 1) @@ -74,46 +87,245 @@ private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var titleStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + titleStack.Children.Add(new TextBlock + { + Text = "Report Preview", + FontSize = 13.5, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(15, 23, 42)) + }); var summary = new TextBlock { - Text = $"{snapshot.IedName} · {snapshot.Rows.Count} row(s) · {snapshot.ProgressText} · captured {snapshot.CapturedAt:yyyy-MM-dd HH:mm:ss zzz}", + Text = NativeFatPreviewSummary(currentSnapshot), + Margin = new Thickness(0, 2, 0, 0), + FontSize = 10.5, + Foreground = new SolidColorBrush(Color.FromRgb(100, 116, 139)) + }; + titleStack.Children.Add(summary); + toolbarGrid.Children.Add(titleStack); + + var viewer = new DocumentViewer + { + Document = document, + Margin = new Thickness(12), + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + Zoom = 100d + }; + + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(16, 0, 0, 0) + }; + + var pageText = new TextBlock + { + Text = "Page — / —", + MinWidth = 76, + Margin = new Thickness(7, 0, 7, 0), + VerticalAlignment = VerticalAlignment.Center, + TextAlignment = TextAlignment.Center, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(71, 85, 105)) + }; + var zoomText = new TextBlock + { + Text = "100%", + MinWidth = 46, + Margin = new Thickness(5, 0, 5, 0), VerticalAlignment = VerticalAlignment.Center, - FontSize = 11.5, + TextAlignment = TextAlignment.Center, + FontSize = 10.5, FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(51, 65, 85)) + Foreground = new SolidColorBrush(Color.FromRgb(71, 85, 105)) }; - toolbarGrid.Children.Add(summary); + + void UpdateViewerState() + { + pageText.Text = viewer.PageCount > 0 + ? $"Page {Math.Max(1, viewer.MasterPageNumber)} / {viewer.PageCount}" + : "Page — / —"; + zoomText.Text = $"{viewer.Zoom:0}%"; + } + + Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) + { + var button = new Button + { + Width = 32, + Height = 30, + Padding = new Thickness(6), + Margin = new Thickness(2, 0, 2, 0), + Style = TryFindResource("SoftButton") as Style, + ToolTip = toolTip, + Cursor = Cursors.Hand, + Content = BuildNativePreviewLucideIcon(icon) + }; + button.Click += (_, _) => + { + action(); + preview.Dispatcher.BeginInvoke(UpdateViewerState, DispatcherPriority.Background); + }; + return button; + } + + actions.Children.Add(IconButton(NativePreviewLucideIcon.Printer, "Print report", viewer.Print)); + actions.Children.Add(IconButton(NativePreviewLucideIcon.Minus, "Zoom out", viewer.DecreaseZoom)); + actions.Children.Add(zoomText); + actions.Children.Add(IconButton(NativePreviewLucideIcon.Plus, "Zoom in", viewer.IncreaseZoom)); + actions.Children.Add(IconButton(NativePreviewLucideIcon.Maximize2, "Fit report page to width", viewer.FitToWidth)); + actions.Children.Add(IconButton(NativePreviewLucideIcon.ChevronLeft, "Previous page", viewer.PreviousPage)); + actions.Children.Add(pageText); + actions.Children.Add(IconButton(NativePreviewLucideIcon.ChevronRight, "Next page", viewer.NextPage)); + + actions.Children.Add(IconButton(NativePreviewLucideIcon.RefreshCw, "Refresh from current FAT evidence", () => + { + var device = SelectedDevice; + if (device == null || device.Points.Count == 0) + return; + + CommitNativeFatEvidenceEdits(); + currentSnapshot = NativeFatPrintPreviewSnapshot.Capture( + device, + GetNativeFatSession(device.DeviceId)); + currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true); + viewer.Document = IoFatReportPreviewDocumentBuilder.Render(currentLayout); + summary.Text = NativeFatPreviewSummary(currentSnapshot); + SetStatus($"FAT · Print Preview refreshed from {currentSnapshot.IedName} evidence"); + })); var savePdfButton = new Button { - Content = "Save PDF", - MinWidth = 96, - Padding = new Thickness(14, 7, 14, 7), + Height = 30, + MinWidth = 94, + Padding = new Thickness(9, 0, 10, 0), + Margin = new Thickness(8, 0, 2, 0), Style = TryFindResource("PrimaryButton") as Style, - ToolTip = "Save this exact immutable preview layout as PDF." + ToolTip = "Save the exact layout currently shown in Print Preview as PDF.", + Cursor = Cursors.Hand, + Content = BuildNativePreviewLabeledContent(NativePreviewLucideIcon.Save, "Save PDF") }; - savePdfButton.Click += (_, _) => SaveNativeFatPreviewPdf(preview, snapshot, layout); - Grid.SetColumn(savePdfButton, 1); - toolbarGrid.Children.Add(savePdfButton); + savePdfButton.Click += (_, _) => SaveNativeFatPreviewPdf(preview, currentSnapshot, currentLayout); + actions.Children.Add(savePdfButton); + actions.Children.Add(IconButton(NativePreviewLucideIcon.X, "Close preview", preview.Close)); + + Grid.SetColumn(actions, 1); + toolbarGrid.Children.Add(actions); toolbar.Child = toolbarGrid; root.Children.Add(toolbar); - var viewer = new DocumentViewer + viewer.Loaded += (_, _) => { - Document = document, - Margin = new Thickness(12), - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Stretch + CollapseNativeDocumentViewerChrome(viewer); + viewer.FitToWidth(); + preview.Dispatcher.BeginInvoke(UpdateViewerState, DispatcherPriority.Background); }; + viewer.PageViewsChanged += (_, _) => UpdateViewerState(); Grid.SetRow(viewer, 1); root.Children.Add(viewer); - // DocumentViewer is the WPF FixedDocument authority for P4D. Its native chrome - // provides pagination, zoom and print without rebuilding the report as a DataGrid. preview.Content = root; preview.Show(); } + private void CommitNativeFatEvidenceEdits() + { + _nativeFatCanonicalGrid?.CommitEdit(DataGridEditingUnit.Cell, true); + _nativeFatCanonicalGrid?.CommitEdit(DataGridEditingUnit.Row, true); + } + + private static string NativeFatPreviewSummary(NativeFatPrintPreviewSnapshot snapshot) + => $"{snapshot.IedName} · {snapshot.Rows.Count} row(s) · {snapshot.ProgressText} · captured {snapshot.CapturedAt:yyyy-MM-dd HH:mm:ss zzz}"; + + private static FrameworkElement BuildNativePreviewLabeledContent(NativePreviewLucideIcon icon, string label) + { + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }; + panel.Children.Add(BuildNativePreviewLucideIcon(icon)); + panel.Children.Add(new TextBlock + { + Text = label, + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold + }); + return panel; + } + + private static Viewbox BuildNativePreviewLucideIcon(NativePreviewLucideIcon icon) + { + // Same 24x24 vector language used by the legacy professional report UX. + var geometry = icon switch + { + NativePreviewLucideIcon.Printer => "M6,9 L6,2 L18,2 L18,9 M6,18 L4,18 C2.9,18 2,17.1 2,16 L2,11 C2,9.9 2.9,9 4,9 L20,9 C21.1,9 22,9.9 22,11 L22,16 C22,17.1 21.1,18 20,18 L18,18 M6,14 L18,14 L18,22 L6,22 Z", + NativePreviewLucideIcon.Minus => "M5,12 L19,12", + NativePreviewLucideIcon.Plus => "M12,5 L12,19 M5,12 L19,12", + NativePreviewLucideIcon.Maximize2 => "M8,3 L3,3 L3,8 M16,3 L21,3 L21,8 M8,21 L3,21 L3,16 M16,21 L21,21 L21,16", + NativePreviewLucideIcon.ChevronLeft => "M15,18 L9,12 L15,6", + NativePreviewLucideIcon.ChevronRight => "M9,18 L15,12 L9,6", + NativePreviewLucideIcon.RefreshCw => "M3,12 A9,9 0 0 1 12,3 A9.75,9.75 0 0 1 18.74,5.74 L21,8 M21,3 L21,8 L16,8 M21,12 A9,9 0 0 1 12,21 A9.75,9.75 0 0 1 5.26,18.26 L3,16 M8,16 L3,16 L3,21", + NativePreviewLucideIcon.Save => "M15.2,3 A2,2 0 0 1 16.6,3.6 L20.4,7.4 A2,2 0 0 1 21,8.8 L21,19 A2,2 0 0 1 19,21 L5,21 A2,2 0 0 1 3,19 L3,5 A2,2 0 0 1 5,3 Z M17,21 L17,14 A1,1 0 0 0 16,13 L8,13 A1,1 0 0 0 7,14 L7,21 M7,3 L7,7 A1,1 0 0 0 8,8 L15,8", + NativePreviewLucideIcon.X => "M18,6 L6,18 M6,6 L18,18", + _ => "M5,12 L19,12" + }; + + var path = new Path + { + Data = Geometry.Parse(geometry), + Fill = Brushes.Transparent, + StrokeThickness = 1.8, + StrokeStartLineCap = PenLineCap.Round, + StrokeEndLineCap = PenLineCap.Round, + StrokeLineJoin = PenLineJoin.Round, + Stretch = Stretch.Uniform + }; + path.SetBinding( + Shape.StrokeProperty, + new Binding(nameof(Control.Foreground)) + { + RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(Button), 1) + }); + + return new Viewbox + { + Width = 16, + Height = 16, + Child = path, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }; + } + + private static void CollapseNativeDocumentViewerChrome(DocumentViewer viewer) + { + foreach (var toolbar in NativePreviewVisualDescendants(viewer)) + toolbar.Visibility = Visibility.Collapsed; + } + + private static IEnumerable NativePreviewVisualDescendants(DependencyObject root) + where T : DependencyObject + { + var count = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < count; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is T typed) + yield return typed; + foreach (var nested in NativePreviewVisualDescendants(child)) + yield return nested; + } + } + private void SaveNativeFatPreviewPdf( Window owner, NativeFatPrintPreviewSnapshot snapshot, @@ -140,7 +352,6 @@ private void SaveNativeFatPreviewPdf( ?? snapshot.DeviceId; // Critical P4D invariant: serialize the exact layout already rendered above. - // Do not rebuild a project, snapshot, row list, SCL model, or report layout here. IoFatPdfReportService.SaveLayout( dialog.FileName, layout, From bc866196e18484239264c4e4c265117e91d212e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:06:21 +0700 Subject: [PATCH 094/158] test(fat): lock professional preview and nine-column report --- .../NativeFatP4DFixedDocumentPreviewTests.cs | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs index c0a328aa1..14bf7e56b 100644 --- a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -7,18 +7,26 @@ namespace ARSAS.Tests; public sealed class NativeFatP4DFixedDocumentPreviewTests { [Fact] - public void P4D_PreviewUsesExistingFixedDocumentAuthorityInsteadOfDataGrid() + public void P4D_PreviewUsesExistingFixedDocumentAuthorityWithProfessionalToolbar() { var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); var renderer = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs")); - Assert.Contains("NativeFatP4DReportAdapter.Build(snapshot, draft: true)", preview, StringComparison.Ordinal); - Assert.Contains("IoFatReportPreviewDocumentBuilder.Render(layout)", preview, StringComparison.Ordinal); + Assert.Contains("NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true)", preview, StringComparison.Ordinal); + Assert.Contains("IoFatReportPreviewDocumentBuilder.Render(currentLayout)", preview, StringComparison.Ordinal); Assert.Contains("new DocumentViewer", preview, StringComparison.Ordinal); - Assert.Contains("Document = document", preview, StringComparison.Ordinal); + Assert.Contains("CollapseNativeDocumentViewerChrome(viewer)", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Printer", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Minus", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Plus", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Maximize2", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.ChevronLeft", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.ChevronRight", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.RefreshCw", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Save", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.X", preview, StringComparison.Ordinal); Assert.DoesNotContain("new DataGrid", preview, StringComparison.Ordinal); - Assert.DoesNotContain("AddPreviewColumn", preview, StringComparison.Ordinal); Assert.DoesNotContain("ItemsSource = snapshot.Rows", preview, StringComparison.Ordinal); Assert.Contains("IoFatReportLayoutPlan Build", adapter, StringComparison.Ordinal); @@ -28,24 +36,23 @@ public void P4D_PreviewUsesExistingFixedDocumentAuthorityInsteadOfDataGrid() } [Fact] - public void P4D_SavePdfSerializesTheExactLayoutAlreadyRenderedInPreview() + public void P4D_SavePdfSerializesTheExactLayoutCurrentlyRenderedInPreview() { var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); var pdfService = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatPdfReportService.cs")); var pdfWriter = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatNativePdfWriter.cs")); - Assert.Contains("var layout = NativeFatP4DReportAdapter.Build(snapshot, draft: true);", preview, StringComparison.Ordinal); - Assert.Contains("IoFatReportPreviewDocumentBuilder.Render(layout)", preview, StringComparison.Ordinal); - Assert.Contains("Content = \"Save PDF\"", preview, StringComparison.Ordinal); + Assert.Contains("var currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true);", preview, StringComparison.Ordinal); + Assert.Contains("Document = document", preview, StringComparison.Ordinal); + Assert.Contains("viewer.Document = IoFatReportPreviewDocumentBuilder.Render(currentLayout)", preview, StringComparison.Ordinal); + Assert.Contains("BuildNativePreviewLabeledContent(NativePreviewLucideIcon.Save, \"Save PDF\")", preview, StringComparison.Ordinal); + Assert.Contains("SaveNativeFatPreviewPdf(preview, currentSnapshot, currentLayout)", preview, StringComparison.Ordinal); Assert.Contains("IoFatPdfReportService.SaveLayout(", preview, StringComparison.Ordinal); Assert.Contains("layout,", preview, StringComparison.Ordinal); Assert.Contains("internal static void SaveLayout(", pdfService, StringComparison.Ordinal); Assert.Contains("GenerateLayout(layout, reportName, primaryReference)", pdfService, StringComparison.Ordinal); Assert.Contains("IoFatNativePdfWriter.Build(layout, reportName, primaryReference)", pdfService, StringComparison.Ordinal); Assert.Contains("public static byte[] Build(", pdfWriter, StringComparison.Ordinal); - Assert.Contains("IoFatReportLayoutPlan layout,", pdfWriter, StringComparison.Ordinal); - Assert.Contains("string reportName,", pdfWriter, StringComparison.Ordinal); - Assert.Contains("string primaryReference)", pdfWriter, StringComparison.Ordinal); } [Fact] @@ -62,7 +69,7 @@ public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild { DeviceId = device.DeviceId, DeviceName = device.Name, - SignalName = "Breaker", + SignalName = "52_ACB1 Status", IecReference = "AA1E1F06R4LD0/XCBR1.Pos.stVal", Quality = "Good", Value = "Open [01]" @@ -75,7 +82,7 @@ public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild NativeFatEvidenceField.Value1, "Open [01]", ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.OperatorSnapshot, - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow.AddSeconds(-1)); NativeFatCanonicalEvidenceOverlay.WriteCapture( cache, point, @@ -88,13 +95,20 @@ public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild var layout = NativeFatP4DReportAdapter.Build(snapshot, draft: true); var bytes = IoFatPdfReportService.GenerateLayout(layout, snapshot.IedName, snapshot.Rows[0].IecTelegram); - Assert.NotEmpty(layout.Pages); + Assert.Equal("52_ACB1 Status", snapshot.Rows[0].Signal); + Assert.Equal("COMPLETE", snapshot.Rows[0].Result); + Assert.False(string.IsNullOrWhiteSpace(snapshot.Rows[0].Value1TimestampText)); + Assert.False(string.IsNullOrWhiteSpace(snapshot.Rows[0].Value2TimestampText)); + Assert.True(layout.Pages.Count >= 2); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "TESTED BY"); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "WITNESSED BY"); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "APPROVED BY"); Assert.True(bytes.Length > 32); Assert.Equal("%PDF-1.4", Encoding.ASCII.GetString(bytes, 0, 8)); } [Fact] - public void P4D_ReportAdapterLocksExactP4CColumns() + public void P4D_ReportAdapterLocksExactNineColumnP4CContract() { var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); var snapshot = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); @@ -104,7 +118,9 @@ public void P4D_ReportAdapterLocksExactP4CColumns() var quality = adapter.IndexOf("\"Quality\"", StringComparison.Ordinal); var live = adapter.IndexOf("\"Live Value\"", StringComparison.Ordinal); var value1 = adapter.IndexOf("\"Value 1\"", StringComparison.Ordinal); + var timestamp1 = adapter.IndexOf("\"V1 Timestamp\"", StringComparison.Ordinal); var value2 = adapter.IndexOf("\"Value 2\"", StringComparison.Ordinal); + var timestamp2 = adapter.IndexOf("\"V2 Timestamp\"", StringComparison.Ordinal); var result = adapter.IndexOf("\"Result\"", StringComparison.Ordinal); Assert.True(signal >= 0); @@ -112,16 +128,19 @@ public void P4D_ReportAdapterLocksExactP4CColumns() Assert.True(quality > telegram); Assert.True(live > quality); Assert.True(value1 > live); - Assert.True(value2 > value1); - Assert.True(result > value2); + Assert.True(timestamp1 > value1); + Assert.True(value2 > timestamp1); + Assert.True(timestamp2 > value2); + Assert.True(result > timestamp2); Assert.DoesNotContain("\"Type\"", adapter, StringComparison.Ordinal); Assert.DoesNotContain("\"Status\"", adapter, StringComparison.Ordinal); - Assert.DoesNotContain("\"Timestamp\"", adapter, StringComparison.Ordinal); Assert.DoesNotContain("\"IEC 61850 reference\"", adapter, StringComparison.Ordinal); Assert.Contains("string IecTelegram", snapshot, StringComparison.Ordinal); Assert.Contains("string Quality", snapshot, StringComparison.Ordinal); + Assert.Contains("string Value1TimestampText", snapshot, StringComparison.Ordinal); + Assert.Contains("string Value2TimestampText", snapshot, StringComparison.Ordinal); Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadDisplay", snapshot, StringComparison.Ordinal); Assert.DoesNotContain("string Type", snapshot, StringComparison.Ordinal); Assert.DoesNotContain("string Status", snapshot, StringComparison.Ordinal); @@ -140,7 +159,8 @@ public void P4D_PreviewDoesNotReintroduceRuntimeOrSclBootstrap() "PrepareIoTestIedForFatAsync", "OpenDescribedSourcesAsync", "IoFatEngineeringWorkspaceProjectionService", - "FatSclWorkspaceImportService" + "FatSclWorkspaceImportService", + "new IoTestProject" }) { Assert.DoesNotContain(forbidden, preview, StringComparison.Ordinal); From 332850951fd4e7031493e85286da6e83e51c879b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:10:15 +0700 Subject: [PATCH 095/158] fix(fat): refresh timestamp evidence with value cells --- .../NativeFatCanonicalEvidenceOverlay.cs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs index ba4bf2810..b495fa634 100644 --- a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs +++ b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs @@ -7,7 +7,9 @@ namespace ArIED61850Tester.Services.IoTesting; public enum NativeFatEvidenceField { Value1, + Value1Timestamp, Value2, + Value2Timestamp, Result } @@ -61,7 +63,9 @@ public static string Read( return field switch { NativeFatEvidenceField.Value1 => RawValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value1Timestamp => TimestampValue(slot.Value1Evidence), NativeFatEvidenceField.Value2 => RawValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence), NativeFatEvidenceField.Result => ResolveResult(slot), _ => string.Empty }; @@ -86,7 +90,9 @@ public static string ReadRaw( return field switch { NativeFatEvidenceField.Value1 => RawValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value1Timestamp => TimestampValue(slot.Value1Evidence), NativeFatEvidenceField.Value2 => RawValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence), NativeFatEvidenceField.Result => slot.Result, _ => string.Empty }; @@ -115,7 +121,9 @@ public static string ReadDisplay( return field switch { NativeFatEvidenceField.Value1 => DisplayValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value1Timestamp => TimestampValue(slot.Value1Evidence), NativeFatEvidenceField.Value2 => DisplayValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence), NativeFatEvidenceField.Result => ResolveResult(slot), _ => string.Empty }; @@ -139,8 +147,8 @@ public static string ReadDisplay( return field switch { - NativeFatEvidenceField.Value1 => slot.Value1Evidence, - NativeFatEvidenceField.Value2 => slot.Value2Evidence, + NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value1Timestamp => slot.Value1Evidence, + NativeFatEvidenceField.Value2 or NativeFatEvidenceField.Value2Timestamp => slot.Value2Evidence, _ => null }; } @@ -154,6 +162,8 @@ public static void Write( { ArgumentNullException.ThrowIfNull(cache); ArgumentNullException.ThrowIfNull(point); + if (field is NativeFatEvidenceField.Value1Timestamp or NativeFatEvidenceField.Value2Timestamp) + return; if (!TryBuildRowKey(point, out var key)) return; @@ -387,6 +397,14 @@ private static string DisplayValue(FatValueEvidence? evidence, string legacyRaw) return $"{raw} - {timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)}"; } + private static string TimestampValue(FatValueEvidence? evidence) + { + if (evidence is null) + return string.Empty; + var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; + return timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + private static string RawValue(FatValueEvidence? evidence, string legacyRaw) => !string.IsNullOrWhiteSpace(evidence?.RawValue) ? evidence.RawValue.Trim() From 4fe1fa49b3108f8ef477f0c2e816334dff775956 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:10:29 +0700 Subject: [PATCH 096/158] fix(fat): refresh timestamp columns through evidence contract --- MainWindow.NativeFatP4CColumnContract.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/MainWindow.NativeFatP4CColumnContract.cs b/MainWindow.NativeFatP4CColumnContract.cs index 4b1eb8e89..e2baf217c 100644 --- a/MainWindow.NativeFatP4CColumnContract.cs +++ b/MainWindow.NativeFatP4CColumnContract.cs @@ -31,15 +31,24 @@ private void ApplyNativeFatP4CColumnContract() _nativeFatCanonicalGrid.Columns.Add( new NativeFatEvidenceColumn(this, "Value 1", NativeFatEvidenceField.Value1, 120)); _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceTimestampColumn(this, "V1 Timestamp", NativeFatEvidenceField.Value1, 185)); + new NativeFatEvidenceColumn(this, "V1 Timestamp", NativeFatEvidenceField.Value1Timestamp, 185) + { + IsReadOnly = true + }); _nativeFatCanonicalGrid.Columns.Add( new NativeFatEvidenceColumn(this, "Value 2", NativeFatEvidenceField.Value2, 120)); _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceTimestampColumn(this, "V2 Timestamp", NativeFatEvidenceField.Value2, 185)); + new NativeFatEvidenceColumn(this, "V2 Timestamp", NativeFatEvidenceField.Value2Timestamp, 185) + { + IsReadOnly = true + }); _nativeFatCanonicalGrid.Columns.Add( new NativeFatEvidenceColumn(this, "Result", NativeFatEvidenceField.Result, 110)); } + // Retained as an isolated formatter for report/tests and compatibility paths. The visible + // grid now routes timestamp fields through NativeFatEvidenceColumn so the existing evidence + // refresh loop updates Value, Timestamp and Result atomically after capture and hydration. private string ReadNativeFatTimestamp(Iec61850MonitorPoint point, NativeFatEvidenceField field) { if (string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) || @@ -57,8 +66,9 @@ private string ReadNativeFatTimestamp(Iec61850MonitorPoint point, NativeFatEvide } /// - /// Read-only timestamp companion to the editable Value 1 / Value 2 evidence columns. - /// It reads the same stable IEDName + IEC Telegram overlay and owns no row collection. + /// Compatibility timestamp column retained for source/binary compatibility. The production + /// P4C grid uses read-only NativeFatEvidenceColumn timestamp fields so its established + /// RefreshNativeFatEvidenceCells loop refreshes all five evidence cells together. /// private sealed class NativeFatEvidenceTimestampColumn : DataGridColumn { From bd7ff38dc2c78e4c80e7a5ef2358e4295a0684c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:10:54 +0700 Subject: [PATCH 097/158] test(fat): lock live timestamp refresh in nine-column grid --- ...ativeFatP4CCanonicalColumnContractTests.cs | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs index 1049ca268..bbcce276b 100644 --- a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs @@ -3,7 +3,7 @@ namespace ARSAS.Tests; public sealed class NativeFatP4CCanonicalColumnContractTests { [Fact] - public void P4C_FatExposesExactlyExplorerColumnsPlusEvidence() + public void P4C_FatExposesExactNineColumnExplorerEvidenceContract() { var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); @@ -14,7 +14,9 @@ public void P4C_FatExposesExactlyExplorerColumnsPlusEvidence() var quality = source.IndexOf("AddCanonicalTextColumn(\"Quality\"", StringComparison.Ordinal); var liveValue = source.IndexOf("AddCanonicalTemplateColumn(\"Live Value\"", StringComparison.Ordinal); var value1 = source.IndexOf("\"Value 1\", NativeFatEvidenceField.Value1", StringComparison.Ordinal); + var timestamp1 = source.IndexOf("\"V1 Timestamp\", NativeFatEvidenceField.Value1Timestamp", StringComparison.Ordinal); var value2 = source.IndexOf("\"Value 2\", NativeFatEvidenceField.Value2", StringComparison.Ordinal); + var timestamp2 = source.IndexOf("\"V2 Timestamp\", NativeFatEvidenceField.Value2Timestamp", StringComparison.Ordinal); var result = source.IndexOf("\"Result\", NativeFatEvidenceField.Result", StringComparison.Ordinal); Assert.True(signal >= 0); @@ -22,8 +24,10 @@ public void P4C_FatExposesExactlyExplorerColumnsPlusEvidence() Assert.True(quality > telegram); Assert.True(liveValue > quality); Assert.True(value1 > liveValue); - Assert.True(value2 > value1); - Assert.True(result > value2); + Assert.True(timestamp1 > value1); + Assert.True(value2 > timestamp1); + Assert.True(timestamp2 > value2); + Assert.True(result > timestamp2); Assert.Contains("_nativeFatCanonicalGrid.Columns.Clear();", source, StringComparison.Ordinal); Assert.Contains("ApplyNativeFatP4CColumnContract();", gridSource, StringComparison.Ordinal); @@ -35,11 +39,25 @@ public void P4C_FatExposesExactlyExplorerColumnsPlusEvidence() Assert.DoesNotContain("\"Address\"", source, StringComparison.Ordinal); Assert.DoesNotContain("\"Message\"", source, StringComparison.Ordinal); Assert.DoesNotContain("\"Data Reference\"", source, StringComparison.Ordinal); - Assert.DoesNotContain("\"Timestamp\"", source, StringComparison.Ordinal); Assert.DoesNotContain("ObservableCollection", source, StringComparison.Ordinal); Assert.DoesNotContain("new Iec61850MonitorPoint", source, StringComparison.Ordinal); } + [Fact] + public void P4C_TimestampColumnsShareTheEvidenceRefreshAuthority() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var overlay = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); + + Assert.Contains("NativeFatEvidenceField.Value1Timestamp", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2Timestamp", source, StringComparison.Ordinal); + Assert.Contains("IsReadOnly = true", source, StringComparison.Ordinal); + Assert.Contains("Columns.OfType()", gridSource, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value1Timestamp => TimestampValue(slot.Value1Evidence)", overlay, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence)", overlay, StringComparison.Ordinal); + } + [Fact] public void P4C_CanonicalGridBuilderHasNoLegacyColumnInstallationPath() { From c94e4c95e6973e9230578238cafc4118d967af2b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:13:50 +0700 Subject: [PATCH 098/158] fix(fat): surface observation progress after evidence capture --- MainWindow.NativeFatP4CColumnContract.cs | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/MainWindow.NativeFatP4CColumnContract.cs b/MainWindow.NativeFatP4CColumnContract.cs index e2baf217c..5525a94a0 100644 --- a/MainWindow.NativeFatP4CColumnContract.cs +++ b/MainWindow.NativeFatP4CColumnContract.cs @@ -2,6 +2,7 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Media; +using System.Windows.Threading; using ArIED61850Tester.Models; using ArIED61850Tester.Services.IoTesting; @@ -9,6 +10,8 @@ namespace ArIED61850Tester; public partial class MainWindow { + private bool _nativeFatObservationStatusHooked; + /// /// Native FAT is a thin view over the canonical IEC Explorer rows. The grid keeps /// SelectedDevice.Points as its ItemsSource and adds only sparse evidence columns. @@ -20,6 +23,15 @@ private void ApplyNativeFatP4CColumnContract() if (_nativeFatCanonicalGrid == null) return; + if (!_nativeFatObservationStatusHooked) + { + // Keep observation progress on the same evidence event authority as V1/V2/Result. + // The callback is posted at Background priority so the ARM click's generic status + // cannot overwrite the more useful row-level "1 / 2" or "2 / 2" confirmation. + _nativeFatArmCoordinator.EvidenceChanged += NativeFatObservationStatus_EvidenceChanged; + _nativeFatObservationStatusHooked = true; + } + _nativeFatCanonicalGrid.Columns.Clear(); _nativeFatCanonicalGrid.FrozenColumnCount = 2; @@ -46,6 +58,36 @@ private void ApplyNativeFatP4CColumnContract() new NativeFatEvidenceColumn(this, "Result", NativeFatEvidenceField.Result, 110)); } + private void NativeFatObservationStatus_EvidenceChanged(object? sender, NativeFatEvidenceChangedEventArgs e) + { + void UpdateObservationStatus() + { + if (_nativeFatStatusText == null || + !string.Equals(_nativeFatBoundIedKey, e.DeviceId, StringComparison.OrdinalIgnoreCase) || + !_nativeFatSessionByIed.TryGetValue(e.DeviceId, out var cache)) + { + return; + } + + var value1 = NativeFatCanonicalEvidenceOverlay.Read(cache, e.Point, NativeFatEvidenceField.Value1); + var value2 = NativeFatCanonicalEvidenceOverlay.Read(cache, e.Point, NativeFatEvidenceField.Value2); + var observations = (string.IsNullOrWhiteSpace(value1) ? 0 : 1) + + (string.IsNullOrWhiteSpace(value2) ? 0 : 1); + var result = NativeFatCanonicalEvidenceOverlay.Read(cache, e.Point, NativeFatEvidenceField.Result); + var signal = string.IsNullOrWhiteSpace(e.Point.SignalName) + ? e.Point.IecTelegram + : e.Point.SignalName.Trim(); + + _nativeFatStatusText.Text = string.IsNullOrWhiteSpace(result) + ? $"{signal} · {observations} / 2 observations" + : $"{signal} · {observations} / 2 observations · {result}"; + } + + Dispatcher.BeginInvoke( + DispatcherPriority.Background, + new Action(UpdateObservationStatus)); + } + // Retained as an isolated formatter for report/tests and compatibility paths. The visible // grid now routes timestamp fields through NativeFatEvidenceColumn so the existing evidence // refresh loop updates Value, Timestamp and Result atomically after capture and hydration. From 0288bd2bb9ce09b19f229a443e12185da54394af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:14:19 +0700 Subject: [PATCH 099/158] test(fat): lock one-of-two and two-of-two observation feedback --- .../NativeFatP4CCanonicalColumnContractTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs index bbcce276b..46772fe56 100644 --- a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs @@ -58,6 +58,21 @@ public void P4C_TimestampColumnsShareTheEvidenceRefreshAuthority() Assert.Contains("NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence)", overlay, StringComparison.Ordinal); } + [Fact] + public void P4C_EvidenceEventSurfacesOneOfTwoThenTwoOfTwoWithCompleteResult() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var overlay = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); + + Assert.Contains("_nativeFatArmCoordinator.EvidenceChanged += NativeFatObservationStatus_EvidenceChanged", source, StringComparison.Ordinal); + Assert.Contains("DispatcherPriority.Background", source, StringComparison.Ordinal); + Assert.Contains("{observations} / 2 observations", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value1", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Result", source, StringComparison.Ordinal); + Assert.Contains("HasValue1(slot) && HasValue2(slot) ? \"COMPLETE\"", overlay, StringComparison.Ordinal); + } + [Fact] public void P4C_CanonicalGridBuilderHasNoLegacyColumnInstallationPath() { From dd2ecfa8ca15c216b977f9c2490997ef976b225b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:22:57 +0700 Subject: [PATCH 100/158] fix(fat): restore print snapshot evidence namespace --- Services/IoTesting/NativeFatPrintPreviewSnapshot.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index ca26e9eed..d0d037822 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Globalization; using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; namespace ArIED61850Tester.Services.IoTesting; @@ -101,4 +102,4 @@ private static string Copy(string? value) private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); -} +} \ No newline at end of file From 09dbcd9fe10e0d65bd7ad48400c594a232337a9a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:29:08 +0700 Subject: [PATCH 101/158] fix(fat): unify preview timestamp contract --- Services/IoTesting/NativeFatPrintPreviewSnapshot.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index d0d037822..0ed031eb4 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -11,9 +11,9 @@ public sealed record NativeFatPrintPreviewRow( string Quality, string LiveValue, string Value1, - string Value1Timestamp, + string Value1TimestampText, string Value2, - string Value2Timestamp, + string Value2TimestampText, string Result); /// From dd793e6155341b3dd6fcf0a996a519b784a4e025 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 21:30:06 +0700 Subject: [PATCH 102/158] fix(fat): disambiguate preview path types --- MainWindow.NativeFatPrintPreview.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs index 56c86c2e1..5b72f0aa4 100644 --- a/MainWindow.NativeFatPrintPreview.cs +++ b/MainWindow.NativeFatPrintPreview.cs @@ -278,7 +278,7 @@ private static Viewbox BuildNativePreviewLucideIcon(NativePreviewLucideIcon icon _ => "M5,12 L19,12" }; - var path = new Path + var path = new System.Windows.Shapes.Path { Data = Geometry.Parse(geometry), Fill = Brushes.Transparent, @@ -374,10 +374,10 @@ private void SaveNativeFatPreviewPdf( private static string BuildNativeFatPdfFileName(string? iedName) { var source = string.IsNullOrWhiteSpace(iedName) ? "IED" : iedName.Trim(); - var invalid = Path.GetInvalidFileNameChars(); + var invalid = System.IO.Path.GetInvalidFileNameChars(); var safe = new string(source.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); if (safe.Length == 0) safe = "IED"; return $"{safe}-FAT-Evidence.pdf"; } -} +} \ No newline at end of file From 3fd2549e9a405820d2ea657e45a6da5016cdf3b9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 22:03:56 +0700 Subject: [PATCH 103/158] test(fat): lock stable identity against current key authority --- ...NativeFatP4AStableEvidenceIdentityTests.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs b/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs index 189eb902c..8e0b62c38 100644 --- a/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs @@ -111,9 +111,8 @@ public void P4A_SourceContract_RejectsIndexDisplayAndRuntimeIdentityFallbacks() Assert.Contains("point.DeviceName", overlay, StringComparison.Ordinal); Assert.Contains("point.IecTelegram", overlay, StringComparison.Ordinal); - Assert.Contains("runtime DeviceId", overlay, StringComparison.Ordinal); - Assert.Contains("not used here", overlay, StringComparison.Ordinal); - Assert.DoesNotContain("SelectedIndex", overlay, StringComparison.Ordinal); + Assert.Contains("Runtime DeviceId, row index, SelectedIndex and display labels are never evidence identity.", overlay, StringComparison.Ordinal); + Assert.DoesNotContain("SelectedIndex", ExtractMethod(overlay, "internal static bool TryBuildRowKey(string? iedName, string? iecTelegram, out string rowKey)"), StringComparison.Ordinal); Assert.Contains("identityCount != 1", arm, StringComparison.Ordinal); Assert.Contains("SignalName, SelectedIndex or runtime DeviceId", arm, StringComparison.Ordinal); } @@ -147,6 +146,21 @@ private static Iec61850MonitorPoint Point( Value = value }; + private static string ExtractMethod(string source, string signature) + { + var start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"Could not find method '{signature}'."); + var openBrace = source.IndexOf('{', start); + Assert.True(openBrace >= 0, $"Could not find opening brace for '{signature}'."); + var depth = 0; + for (var index = openBrace; index < source.Length; index++) + { + if (source[index] == '{') depth++; + else if (source[index] == '}' && --depth == 0) return source[start..(index + 1)]; + } + throw new InvalidDataException($"Method '{signature}' has no balanced closing brace."); + } + private static string FindRepoFile(string relativePath) => Path.Combine(FindRepoRoot(), relativePath); @@ -167,4 +181,4 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException( $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } -} +} \ No newline at end of file From c07aee39a0bb27ae90097d7bc09078ab36febd3e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 22:04:15 +0700 Subject: [PATCH 104/158] test(fat): align grid regression with nine-column contract --- .../ProductionFatP1CanonicalGridRegressionTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs index e8acb770e..f425d89e5 100644 --- a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs @@ -25,10 +25,10 @@ public void P1B_EvidenceColumnsRemainSparseOverlayNotRowWrappers() var columnContract = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); var overlaySource = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); - // P4C is the single column authority; evidence field declarations intentionally - // live there rather than in the canonical grid builder. Assert.Contains("NativeFatEvidenceField.Value1", columnContract, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value1Timestamp", columnContract, StringComparison.Ordinal); Assert.Contains("NativeFatEvidenceField.Value2", columnContract, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2Timestamp", columnContract, StringComparison.Ordinal); Assert.Contains("NativeFatEvidenceField.Result", columnContract, StringComparison.Ordinal); Assert.Contains("NativeFatIedSessionCacheState", gridSource, StringComparison.Ordinal); Assert.Contains("TryBuildRowKey(point.DeviceName, point.IecTelegram", overlaySource, StringComparison.Ordinal); @@ -56,13 +56,13 @@ public void P1C_NativeFatUsesEngineeringGridStyleTemplateAndVirtualizationContra Assert.Contains("FindResource(\"ModernDataGrid\") as Style", gridSource, StringComparison.Ordinal); Assert.Contains("ApplyNativeFatP4CColumnContract();", gridSource, StringComparison.Ordinal); - Assert.Contains("AddCanonicalTemplateColumn(\"Live Value\", \"ProcessValueBadgeTemplate\", 140);", columnContract, StringComparison.Ordinal); + Assert.Contains("AddCanonicalTemplateColumn(\"Live Value\", \"ProcessValueBadgeTemplate\", 125);", columnContract, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceColumn(this, \"V1 Timestamp\", NativeFatEvidenceField.Value1Timestamp, 185)", columnContract, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceColumn(this, \"V2 Timestamp\", NativeFatEvidenceField.Value2Timestamp, 185)", columnContract, StringComparison.Ordinal); Assert.Contains("VirtualizingPanel.SetVirtualizationMode(_nativeFatCanonicalGrid, VirtualizationMode.Recycling);", gridSource, StringComparison.Ordinal); Assert.Contains("RowStyle = BuildEngineeringLiveRowStyle()", gridSource, StringComparison.Ordinal); Assert.Contains("CellStyle = BuildEngineeringLiveCellStyle()", gridSource, StringComparison.Ordinal); - // Engineering FAT must inherit the shared 32 px authority rather than the - // legacy IoList FAT local 40 px row family. Assert.DoesNotContain("RowHeight = 40", gridSource, StringComparison.Ordinal); Assert.DoesNotContain("MinHeight = 40", gridSource, StringComparison.Ordinal); } @@ -87,4 +87,4 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException( $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } -} +} \ No newline at end of file From d0f0e94bf625a01dea636b94bf89c30aa03ae2d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 22:04:41 +0700 Subject: [PATCH 105/158] test(fat): assert structured timestamp columns separately --- ...eFatP4BStructuredTimestampEvidenceTests.cs | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs b/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs index a4ff00767..4bd0ba7d9 100644 --- a/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs @@ -35,15 +35,18 @@ public void Arm_CapturesValueWithRelayTimestampQualitySourceAndSequence() Assert.Equal(31, evidence.IedTimestamp!.Value.Second); Assert.Equal(958, evidence.IedTimestamp.Value.Millisecond); Assert.Equal( - "True - 2026-09-12 06:46:31.958", + "True", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal( + "2026-09-12 06:46:31.958", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp)); Assert.Equal( "True", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); } [Fact] - public void CaptureWithoutRelayTimestamp_UsesArsasCaptureTimeAsDisplayFallback() + public void CaptureWithoutRelayTimestamp_UsesArsasCaptureTimeAsTimestampFallback() { using var coordinator = new NativeFatArmCoordinator(); var device = Device("runtime-fallback"); @@ -68,9 +71,10 @@ public void CaptureWithoutRelayTimestamp_UsesArsasCaptureTimeAsDisplayFallback() Assert.NotNull(evidence); Assert.Null(evidence!.IedTimestamp); Assert.InRange(evidence.CapturedAt, before, after); + Assert.Equal("1247.32", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); Assert.Equal( - $"1247.32 - {evidence.CapturedAt:yyyy-MM-dd HH:mm:ss.fff}", - NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + $"{evidence.CapturedAt:yyyy-MM-dd HH:mm:ss.fff}", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp)); } [Fact] @@ -107,11 +111,17 @@ public void RollingPair_PreservesOriginalTimestampWhenValue2BecomesValue1() Assert.Equal("Open [01]", value2!.RawValue); Assert.Equal(300, value2.IedTimestamp!.Value.Millisecond); Assert.Equal( - "Closed [10] - 2026-09-12 06:46:31.200", + "Closed [10]", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); Assert.Equal( - "Open [01] - 2026-09-12 06:46:32.300", + "2026-09-12 06:46:31.200", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp)); + Assert.Equal( + "Open [01]", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal( + "2026-09-12 06:46:32.300", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2Timestamp)); } [Fact] @@ -161,8 +171,11 @@ public async Task PersistHydrate_UsesStableIedNameAndPreservesStructuredEvidence Assert.Equal(77, capture.Sequence); Assert.Equal(958, capture.IedTimestamp!.Value.Millisecond); Assert.Equal( - "True - 2026-09-12 06:46:31.958", + "True", NativeFatCanonicalEvidenceOverlay.Read(restored, recreatedPoint, NativeFatEvidenceField.Value1)); + Assert.Equal( + "2026-09-12 06:46:31.958", + NativeFatCanonicalEvidenceOverlay.Read(restored, recreatedPoint, NativeFatEvidenceField.Value1Timestamp)); } finally { @@ -190,12 +203,15 @@ public void UntouchedRenderedEvidenceCommit_DoesNotRecaptureOrChangeTimestamp() new DateTimeOffset(2026, 9, 12, 8, 1, 3, TimeSpan.FromHours(7))); var before = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); var rendered = NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1); + var renderedTimestamp = NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp); NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, rendered); var after = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); Assert.Same(before, after); - Assert.Equal("False - 2026-09-12 08:01:02.345", rendered); + Assert.Equal("False", rendered); + Assert.Equal("2026-09-12 08:01:02.345", renderedTimestamp); + Assert.Equal(renderedTimestamp, NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp)); } private static Iec61850MonitorDevice Device(string deviceId) @@ -249,4 +265,4 @@ private static void TryDelete(string path) { } } -} +} \ No newline at end of file From cf4a63e7a05bad6ec9d0b56431c62c5fd3ef594e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 22:05:11 +0700 Subject: [PATCH 106/158] test(fat): keep evidence isolation with split timestamps --- .../NativeFatP4EEvidenceIsolationRegressionTests.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs b/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs index ff03a8708..a8121dde2 100644 --- a/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs @@ -46,8 +46,6 @@ public async Task P4E_RestartReorder_CswiEvidenceStaysOnExactTelegramAndNeverMov DateTimeOffset.UtcNow); await service.SaveAsync(before, saved); - // Simulate application/runtime recreation plus the exact row-order inversion that - // previously allowed CSWI evidence to appear on an unrelated THD row. var after = Device("runtime-after", "AA1E1F06R4"); var thdAfter = Point( after, @@ -79,7 +77,10 @@ public async Task P4E_RestartReorder_CswiEvidenceStaysOnExactTelegramAndNeverMov Assert.Equal("LD0/MMXU1.ThdPPV.phsA.cVal.mag.f", snapshot.Rows[0].IecTelegram); Assert.Equal("LD0/CSWI1.Pos.stVal", snapshot.Rows[1].IecTelegram); Assert.Equal("—", snapshot.Rows[0].Value1); - Assert.StartsWith("Open [01] - ", snapshot.Rows[1].Value1, StringComparison.Ordinal); + Assert.Equal("Open [01]", snapshot.Rows[1].Value1); + Assert.NotEqual("—", snapshot.Rows[1].Value1TimestampText); + Assert.Equal("Closed [10]", snapshot.Rows[1].Value2); + Assert.NotEqual("—", snapshot.Rows[1].Value2TimestampText); } finally { @@ -180,7 +181,8 @@ public void P4E_DigitalAnalogPositionAndTapEvidenceKeepMillisecondTimestamp(stri DateTimeOffset.UtcNow); var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); - Assert.Equal($"{rawValue} - 2026-09-12 06:46:31.958", snapshot.Rows[0].Value1); + Assert.Equal(rawValue, snapshot.Rows[0].Value1); + Assert.Equal("2026-09-12 06:46:31.958", snapshot.Rows[0].Value1TimestampText); } [Fact] @@ -286,4 +288,4 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException( $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } -} +} \ No newline at end of file From 27eee871c758adff47f2ce62a300a10ccf1e48da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 22:05:36 +0700 Subject: [PATCH 107/158] test(fat): assert immutable preview with split timestamps --- .../NativeFatP3PrintPreviewTests.cs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs index 919f27d03..cf7de5c71 100644 --- a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs @@ -32,8 +32,10 @@ public void Capture_CopiesSelectedCanonicalRowsInCurrentOrderAndSparseEvidence() Assert.Equal("LD0/XCBR1.Pos.stVal", snapshot.Rows[0].IecTelegram); Assert.Equal("Good", snapshot.Rows[0].Quality); Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); - Assert.StartsWith("Open [01] - ", snapshot.Rows[0].Value1, StringComparison.Ordinal); - Assert.StartsWith("Closed [10] - ", snapshot.Rows[0].Value2, StringComparison.Ordinal); + Assert.Equal("Open [01]", snapshot.Rows[0].Value1); + Assert.NotEqual("—", snapshot.Rows[0].Value1TimestampText); + Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); + Assert.NotEqual("—", snapshot.Rows[0].Value2TimestampText); Assert.Equal("PASS", snapshot.Rows[0].Result); Assert.Equal("1/2 complete", snapshot.ProgressText); } @@ -51,7 +53,9 @@ public void Capture_RemainsImmutableWhenLiveRowsAndEvidenceChange() var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); var capturedValue1 = snapshot.Rows[0].Value1; + var capturedValue1Timestamp = snapshot.Rows[0].Value1TimestampText; var capturedValue2 = snapshot.Rows[0].Value2; + var capturedValue2Timestamp = snapshot.Rows[0].Value2TimestampText; point.Value = "Closed [10]"; point.SignalName = "MUTATED"; @@ -66,9 +70,11 @@ public void Capture_RemainsImmutableWhenLiveRowsAndEvidenceChange() Assert.Equal("Good", snapshot.Rows[0].Quality); Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); Assert.Equal(capturedValue1, snapshot.Rows[0].Value1); + Assert.Equal(capturedValue1Timestamp, snapshot.Rows[0].Value1TimestampText); Assert.Equal(capturedValue2, snapshot.Rows[0].Value2); - Assert.StartsWith("Open [01] - ", snapshot.Rows[0].Value1, StringComparison.Ordinal); - Assert.StartsWith("Closed [10] - ", snapshot.Rows[0].Value2, StringComparison.Ordinal); + Assert.Equal(capturedValue2Timestamp, snapshot.Rows[0].Value2TimestampText); + Assert.Equal("Open [01]", snapshot.Rows[0].Value1); + Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); Assert.Equal("PASS", snapshot.Rows[0].Result); } @@ -114,7 +120,10 @@ public void P3_PreviewCaptureRemainsLazySelectedIedOnly() Assert.Contains("Array.AsReadOnly", snapshotSource, StringComparison.Ordinal); Assert.Contains("Copy(point.IecTelegram)", snapshotSource, StringComparison.Ordinal); Assert.Contains("Copy(point.Quality)", snapshotSource, StringComparison.Ordinal); - Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadDisplay", snapshotSource, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadRaw", snapshotSource, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadCapture", snapshotSource, StringComparison.Ordinal); + Assert.Contains("DisplayTimestamp(capture1)", snapshotSource, StringComparison.Ordinal); + Assert.Contains("DisplayTimestamp(capture2)", snapshotSource, StringComparison.Ordinal); Assert.DoesNotContain("Iec61850MonitorPoint Point", snapshotSource, StringComparison.Ordinal); foreach (var forbidden in new[] @@ -198,4 +207,4 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException( $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } -} +} \ No newline at end of file From 318c65b58977031a88fa964422d01099534d9e05 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 22:06:01 +0700 Subject: [PATCH 108/158] test(fat): lock report preview to split timestamp evidence --- .../ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs index 14bf7e56b..ad66c0e50 100644 --- a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -97,7 +97,9 @@ public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild Assert.Equal("52_ACB1 Status", snapshot.Rows[0].Signal); Assert.Equal("COMPLETE", snapshot.Rows[0].Result); + Assert.Equal("Open [01]", snapshot.Rows[0].Value1); Assert.False(string.IsNullOrWhiteSpace(snapshot.Rows[0].Value1TimestampText)); + Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); Assert.False(string.IsNullOrWhiteSpace(snapshot.Rows[0].Value2TimestampText)); Assert.True(layout.Pages.Count >= 2); Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "TESTED BY"); @@ -141,7 +143,10 @@ public void P4D_ReportAdapterLocksExactNineColumnP4CContract() Assert.Contains("string Quality", snapshot, StringComparison.Ordinal); Assert.Contains("string Value1TimestampText", snapshot, StringComparison.Ordinal); Assert.Contains("string Value2TimestampText", snapshot, StringComparison.Ordinal); - Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadDisplay", snapshot, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadRaw", snapshot, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadCapture", snapshot, StringComparison.Ordinal); + Assert.Contains("DisplayTimestamp(capture1)", snapshot, StringComparison.Ordinal); + Assert.Contains("DisplayTimestamp(capture2)", snapshot, StringComparison.Ordinal); Assert.DoesNotContain("string Type", snapshot, StringComparison.Ordinal); Assert.DoesNotContain("string Status", snapshot, StringComparison.Ordinal); } @@ -188,4 +193,4 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException( $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } -} +} \ No newline at end of file From 67c2d63a8829f4df6c47121386399c8abad6748a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 22:11:57 +0700 Subject: [PATCH 109/158] fix(control): default interlock and sync checks once per signal --- MainWindow.CommandPanelUx.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/MainWindow.CommandPanelUx.cs b/MainWindow.CommandPanelUx.cs index 5d8f96b7c..2a30f9860 100644 --- a/MainWindow.CommandPanelUx.cs +++ b/MainWindow.CommandPanelUx.cs @@ -74,6 +74,7 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu private readonly ConditionalWeakTable _configuredCommandButtons = new(); private readonly ConditionalWeakTable _tactileButtonStates = new(); + private readonly ConditionalWeakTable _controlSafetyDefaultsApplied = new(); private readonly HashSet _controlModelPreloadAttempts = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _controlModelPreloadGate = new(1, 1); @@ -170,6 +171,8 @@ private void ConfigureCommandPanelButton(Button button) if (button.DataContext is not SignalDefinition signal) return; + EnsureDefaultControlSafetyChecks(signal); + var content = button.Content?.ToString()?.Trim() ?? string.Empty; if (content.Equals("Technical details", StringComparison.OrdinalIgnoreCase) || content.Equals("Not available", StringComparison.OrdinalIgnoreCase)) @@ -212,6 +215,20 @@ private void ConfigureCommandPanelButton(Button button) _configuredCommandButtons.Add(button, new Marker()); } + private void EnsureDefaultControlSafetyChecks(SignalDefinition signal) + { + ArgumentNullException.ThrowIfNull(signal); + _controlSafetyDefaultsApplied.GetValue(signal, current => + { + // Safe command defaults are applied to the model once, not painted into XAML. + // After this first initialization the operator remains free to clear either flag; + // the periodic command-panel UX refresh must never force a user choice back on. + current.ControlInterlockCheck = true; + current.ControlSynchroCheck = true; + return new Marker(); + }); + } + private static bool IsCommandActionButton(string content) => content is "Open" or "Close" or "True" or "False" or "Raise" or "Lower" or "Set"; @@ -251,6 +268,9 @@ private async Task PreloadControlModelsAsync() { foreach (var device in Devices.Where(device => device.IsConnected && device.SelectedControlSignalCount > 0)) { + foreach (var signal in device.Signals.Where(signal => signal.IsSelected && signal.IsValidControlObject)) + EnsureDefaultControlSafetyChecks(signal); + // One MMS association is serialized. Do not queue background ctlModel // inspection while an operator command owns the session. if (device.CommandSignals.Any(signal => signal.ControlCommandBusy)) From d49fa22e04d937e45b31e66cb6680718d4789cd2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 12 Sep 2026 22:12:11 +0700 Subject: [PATCH 110/158] test(control): lock safe interlock and sync defaults --- ...FatControlSafetyDefaultsRegressionTests.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/ARSAS.Tests/FatControlSafetyDefaultsRegressionTests.cs diff --git a/tests/ARSAS.Tests/FatControlSafetyDefaultsRegressionTests.cs b/tests/ARSAS.Tests/FatControlSafetyDefaultsRegressionTests.cs new file mode 100644 index 000000000..98432c6ce --- /dev/null +++ b/tests/ARSAS.Tests/FatControlSafetyDefaultsRegressionTests.cs @@ -0,0 +1,54 @@ +namespace ARSAS.Tests; + +public sealed class FatControlSafetyDefaultsRegressionTests +{ + [Fact] + public void CommandSafetyDefaults_AreAppliedToModelOnceAndRemainOperatorEditable() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.CommandPanelUx.cs")); + var xaml = File.ReadAllText(FindRepoFile("MainWindow.xaml")); + + Assert.Contains("ConditionalWeakTable _controlSafetyDefaultsApplied", source, StringComparison.Ordinal); + Assert.Contains("EnsureDefaultControlSafetyChecks(signal);", source, StringComparison.Ordinal); + Assert.Contains("current.ControlInterlockCheck = true;", source, StringComparison.Ordinal); + Assert.Contains("current.ControlSynchroCheck = true;", source, StringComparison.Ordinal); + Assert.Contains("the periodic command-panel UX refresh must never force a user choice back on", source, StringComparison.Ordinal); + + Assert.Contains("Content=\"Interlock\"", xaml, StringComparison.Ordinal); + Assert.Contains("IsChecked=\"{Binding ControlInterlockCheck, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}\"", xaml, StringComparison.Ordinal); + Assert.Contains("Content=\"Sync\"", xaml, StringComparison.Ordinal); + Assert.Contains("IsChecked=\"{Binding ControlSynchroCheck, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}\"", xaml, StringComparison.Ordinal); + + Assert.DoesNotContain("IsChecked=\"True\"", ExtractChecksColumn(xaml), StringComparison.OrdinalIgnoreCase); + } + + private static string ExtractChecksColumn(string source) + { + var start = source.IndexOf("= 0); + var end = source.IndexOf("", start, StringComparison.Ordinal); + Assert.True(end > start); + return source[start..(end + "".Length)]; + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From e03c998c17c13d99caf8d5ed9361fe95bd03a9ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 05:44:57 +0700 Subject: [PATCH 111/158] fix(fat): use shared operator signal names in report snapshots --- Services/IoTesting/NativeFatPrintPreviewSnapshot.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index 0ed031eb4..96197e6b3 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -64,9 +64,10 @@ public static NativeFatPrintPreviewSnapshot Capture( var capture1 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); var capture2 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value2); var result = NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result); + var displaySignal = IoFatSignalDisplayNameFormatter.Format(point.SignalName, point.IecReference); return new NativeFatPrintPreviewRow( - point.SignalName ?? string.Empty, + Copy(displaySignal), Copy(point.IecTelegram), Copy(point.Quality), Display(point.DisplayValue), From 16e5c0e9a9feb2243d6754bf2278b1c86e143920 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 05:45:05 +0700 Subject: [PATCH 112/158] feat(fat): add shared vector report branding --- Services/IoTesting/NativeFatReportBranding.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Services/IoTesting/NativeFatReportBranding.cs diff --git a/Services/IoTesting/NativeFatReportBranding.cs b/Services/IoTesting/NativeFatReportBranding.cs new file mode 100644 index 000000000..13b388ff3 --- /dev/null +++ b/Services/IoTesting/NativeFatReportBranding.cs @@ -0,0 +1,44 @@ +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Shared vector branding for native FAT report layouts. The mark is expressed only through +/// report commands so the WPF preview and PDF writer render the exact same logo without an +/// external bitmap dependency. +/// +internal static class NativeFatReportBranding +{ + private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); + private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); + private static readonly IoFatReportColor White = IoFatReportColor.FromHex("FFFFFF"); + + public static void AddLogo(ICollection commands, double x, double topY) + { + ArgumentNullException.ThrowIfNull(commands); + + commands.Add(new IoFatReportRectCommand( + x, + topY, + 22d, + 22d, + 4d, + Blue, + Blue, + 0d)); + commands.Add(new IoFatReportTextCommand( + x + 5.2d, + topY - 15.2d, + 12d, + "A", + IoFatReportFontKind.Bold, + 11.2d, + White)); + commands.Add(new IoFatReportTextCommand( + x + 29d, + topY - 15.4d, + 72d, + "ARSAS", + IoFatReportFontKind.Bold, + 10.2d, + Navy)); + } +} \ No newline at end of file From 1bdad19b953b3330c3c6f838e37f54265b9da5a2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 05:45:30 +0700 Subject: [PATCH 113/158] fix(fat): clean customer report and map completed rows to OK --- .../IoTesting/NativeFatP4DReportAdapter.cs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs index dc6519f9f..2b0bf27a9 100644 --- a/Services/IoTesting/NativeFatP4DReportAdapter.cs +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -63,7 +63,7 @@ public static IoFatReportLayoutPlan Build(NativeFatPrintPreviewSnapshot snapshot Margin, y - 20d, 600d, - "No canonical FAT row is present in this snapshot.", + "No FAT signal is available for this report.", IoFatReportFontKind.Bold, 8.5d, Attention)); @@ -76,7 +76,7 @@ public static IoFatReportLayoutPlan Build(NativeFatPrintPreviewSnapshot snapshot Margin, 24d, 520d, - $"Immutable Engineering FAT snapshot · {snapshot.CapturedAt:yyyy-MM-dd HH:mm:ss zzz}", + $"FAT evidence captured · {snapshot.CapturedAt:yyyy-MM-dd HH:mm:ss zzz}", IoFatReportFontKind.Regular, 6.2d, Muted)); @@ -110,6 +110,7 @@ private static List NewPage( var page = new List(); pages.Add(page); + NativeFatReportBranding.AddLogo(page, PageWidth - Margin - 102d, 582d); page.Add(new IoFatReportTextCommand( Margin, 562d, @@ -122,7 +123,7 @@ private static List NewPage( Margin, 542d, 560d, - "Canonical Explorer snapshot · sparse FAT evidence · no acquisition restart", + "Factory Acceptance Test · IEC 61850 signal evidence", IoFatReportFontKind.Regular, 7.6d, Muted)); @@ -185,6 +186,7 @@ private static void DrawRow( double height, ref double y) { + var reportResult = ReportResult(row.Result); var cells = new[] { Clean(row.Signal), @@ -195,7 +197,7 @@ private static void DrawRow( Clean(row.Value1TimestampText), Clean(row.Value2), Clean(row.Value2TimestampText), - Clean(row.Result) + reportResult }; var x = Margin; @@ -231,7 +233,7 @@ private static void DrawRow( ? IoFatReportFontKind.Mono : index is 0 or 8 ? IoFatReportFontKind.Bold : IoFatReportFontKind.Regular, isTimestamp ? 4.9d : 5.8d, - index == 8 ? ResultColor(row.Result) : Ink)); + index == 8 ? ResultColor(reportResult) : Ink)); } x += Widths[index]; @@ -254,10 +256,17 @@ private static IReadOnlyList WrapTelegram(string? value) return lines; } + private static string ReportResult(string? result) + { + var value = Clean(result); + return value.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase) ? "OK" : value; + } + private static IoFatReportColor ResultColor(string? result) { var value = Clean(result); - if (value.Contains("PASS", StringComparison.OrdinalIgnoreCase) || + if (value.Equals("OK", StringComparison.OrdinalIgnoreCase) || + value.Contains("PASS", StringComparison.OrdinalIgnoreCase) || value.Contains("COMPLETE", StringComparison.OrdinalIgnoreCase)) return Pass; if (value.Contains("FAIL", StringComparison.OrdinalIgnoreCase)) @@ -269,4 +278,4 @@ private static IoFatReportColor ResultColor(string? result) private static string Clean(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); -} +} \ No newline at end of file From 65086f0ef7313decd197faeea5354b06fc935caa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 05:45:52 +0700 Subject: [PATCH 114/158] fix(fat): remove internal wording from sign-off report --- Services/IoTesting/NativeFatReportFinalization.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Services/IoTesting/NativeFatReportFinalization.cs b/Services/IoTesting/NativeFatReportFinalization.cs index 94025317f..9fafcdcb8 100644 --- a/Services/IoTesting/NativeFatReportFinalization.cs +++ b/Services/IoTesting/NativeFatReportFinalization.cs @@ -51,10 +51,11 @@ private static IoFatReportPagePlan BuildSignOffPage( { var commands = new List(); - Text(commands, Margin, 566d, 490d, "ARSAS | IEC 61850 FAT", IoFatReportFontKind.Bold, 7.2d, Muted); + NativeFatReportBranding.AddLogo(commands, PageWidth - Margin - 102d, 582d); + Text(commands, Margin, 566d, 490d, "IEC 61850 FAT", IoFatReportFontKind.Bold, 7.2d, Muted); Text(commands, Margin, 544d, 520d, "FAT Acceptance Sign-Off", IoFatReportFontKind.Bold, 17.2d, Navy); Text(commands, Margin, 522d, 540d, - "Final acceptance record for the immutable IEC 61850 FAT evidence contained in this report.", + "Final acceptance record for the IEC 61850 FAT evidence in this report.", IoFatReportFontKind.Regular, 8.0d, Muted); Line(commands, Margin, 498d, PageWidth - Margin, 498d, Border, 0.8d); @@ -79,7 +80,7 @@ private static IoFatReportPagePlan BuildSignOffPage( Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); Text(commands, Margin, 24d, 620d, - $"Immutable FAT snapshot · {createdAt:yyyy-MM-dd HH:mm:ss zzz} · blank sign-off fields are intentionally not prefilled", + $"FAT evidence captured · {createdAt:yyyy-MM-dd HH:mm:ss zzz}", IoFatReportFontKind.Regular, 6.2d, Muted); Text(commands, PageWidth - Margin - 118d, 24d, 118d, $"Page {pageNumber} / {totalPages}", @@ -154,4 +155,4 @@ private static void Text( private static string Clean(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); -} +} \ No newline at end of file From 62c45e9a41e0eaf247f80dd5bce1df38e71c03c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 05:47:13 +0700 Subject: [PATCH 115/158] fix(fat): center preview controls and fit whole page --- MainWindow.NativeFatPrintPreview.cs | 34 +++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs index 5b72f0aa4..ae4087676 100644 --- a/MainWindow.NativeFatPrintPreview.cs +++ b/MainWindow.NativeFatPrintPreview.cs @@ -24,8 +24,7 @@ private enum NativePreviewLucideIcon ChevronLeft, ChevronRight, RefreshCw, - Save, - X + Save } private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) @@ -49,7 +48,7 @@ private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) /// /// Professional native preview: immutable selected-IED snapshot -> shared layout adapter -> /// existing FixedDocument renderer. The stock DocumentViewer toolbar is hidden and the - /// ARSAS/Lucide-style toolbar owns print, zoom, fit, page navigation, refresh and Save PDF. + /// ARSAS/Lucide-style toolbar owns print, zoom, whole-page fit, page navigation, refresh and Save PDF. /// Preview and Save PDF always consume the exact same IoFatReportLayoutPlan instance. /// private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) @@ -86,6 +85,7 @@ private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) var toolbarGrid = new Grid(); toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); var titleStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; titleStack.Children.Add(new TextBlock @@ -117,8 +117,8 @@ private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) var actions = new StackPanel { Orientation = Orientation.Horizontal, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(16, 0, 0, 0) + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center }; var pageText = new TextBlock @@ -177,7 +177,7 @@ Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) actions.Children.Add(IconButton(NativePreviewLucideIcon.Minus, "Zoom out", viewer.DecreaseZoom)); actions.Children.Add(zoomText); actions.Children.Add(IconButton(NativePreviewLucideIcon.Plus, "Zoom in", viewer.IncreaseZoom)); - actions.Children.Add(IconButton(NativePreviewLucideIcon.Maximize2, "Fit report page to width", viewer.FitToWidth)); + actions.Children.Add(IconButton(NativePreviewLucideIcon.Maximize2, "Fit whole report page", () => FitNativeReportPage(viewer))); actions.Children.Add(IconButton(NativePreviewLucideIcon.ChevronLeft, "Previous page", viewer.PreviousPage)); actions.Children.Add(pageText); actions.Children.Add(IconButton(NativePreviewLucideIcon.ChevronRight, "Next page", viewer.NextPage)); @@ -195,6 +195,13 @@ Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true); viewer.Document = IoFatReportPreviewDocumentBuilder.Render(currentLayout); summary.Text = NativeFatPreviewSummary(currentSnapshot); + preview.Dispatcher.BeginInvoke( + () => + { + FitNativeReportPage(viewer); + UpdateViewerState(); + }, + DispatcherPriority.Background); SetStatus($"FAT · Print Preview refreshed from {currentSnapshot.IedName} evidence"); })); @@ -211,7 +218,6 @@ Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) }; savePdfButton.Click += (_, _) => SaveNativeFatPreviewPdf(preview, currentSnapshot, currentLayout); actions.Children.Add(savePdfButton); - actions.Children.Add(IconButton(NativePreviewLucideIcon.X, "Close preview", preview.Close)); Grid.SetColumn(actions, 1); toolbarGrid.Children.Add(actions); @@ -221,7 +227,7 @@ Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) viewer.Loaded += (_, _) => { CollapseNativeDocumentViewerChrome(viewer); - viewer.FitToWidth(); + FitNativeReportPage(viewer); preview.Dispatcher.BeginInvoke(UpdateViewerState, DispatcherPriority.Background); }; viewer.PageViewsChanged += (_, _) => UpdateViewerState(); @@ -232,6 +238,17 @@ Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) preview.Show(); } + private static void FitNativeReportPage(DocumentViewer viewer) + { + ArgumentNullException.ThrowIfNull(viewer); + + viewer.FitToWidth(); + var widthZoom = viewer.Zoom; + viewer.FitToHeight(); + var heightZoom = viewer.Zoom; + viewer.Zoom = Math.Min(widthZoom, heightZoom); + } + private void CommitNativeFatEvidenceEdits() { _nativeFatCanonicalGrid?.CommitEdit(DataGridEditingUnit.Cell, true); @@ -274,7 +291,6 @@ private static Viewbox BuildNativePreviewLucideIcon(NativePreviewLucideIcon icon NativePreviewLucideIcon.ChevronRight => "M9,18 L15,12 L9,6", NativePreviewLucideIcon.RefreshCw => "M3,12 A9,9 0 0 1 12,3 A9.75,9.75 0 0 1 18.74,5.74 L21,8 M21,3 L21,8 L16,8 M21,12 A9,9 0 0 1 12,21 A9.75,9.75 0 0 1 5.26,18.26 L3,16 M8,16 L3,16 L3,21", NativePreviewLucideIcon.Save => "M15.2,3 A2,2 0 0 1 16.6,3.6 L20.4,7.4 A2,2 0 0 1 21,8.8 L21,19 A2,2 0 0 1 19,21 L5,21 A2,2 0 0 1 3,19 L3,5 A2,2 0 0 1 5,3 Z M17,21 L17,14 A1,1 0 0 0 16,13 L8,13 A1,1 0 0 0 7,14 L7,21 M7,3 L7,7 A1,1 0 0 0 8,8 L15,8", - NativePreviewLucideIcon.X => "M18,6 L6,18 M6,6 L18,18", _ => "M5,12 L19,12" }; From 050520805a2a244d29076f50d4dd8fbce8343b48 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 05:47:44 +0700 Subject: [PATCH 116/158] test(fat): lock report cleanup and whole-page preview contract --- .../NativeFatP4DFixedDocumentPreviewTests.cs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs index ad66c0e50..f03bc1cf0 100644 --- a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -25,7 +25,12 @@ public void P4D_PreviewUsesExistingFixedDocumentAuthorityWithProfessionalToolbar Assert.Contains("NativePreviewLucideIcon.ChevronRight", preview, StringComparison.Ordinal); Assert.Contains("NativePreviewLucideIcon.RefreshCw", preview, StringComparison.Ordinal); Assert.Contains("NativePreviewLucideIcon.Save", preview, StringComparison.Ordinal); - Assert.Contains("NativePreviewLucideIcon.X", preview, StringComparison.Ordinal); + Assert.DoesNotContain("NativePreviewLucideIcon.X", preview, StringComparison.Ordinal); + Assert.Contains("HorizontalAlignment = HorizontalAlignment.Center", preview, StringComparison.Ordinal); + Assert.Contains("FitNativeReportPage(viewer)", preview, StringComparison.Ordinal); + Assert.Contains("viewer.FitToWidth()", preview, StringComparison.Ordinal); + Assert.Contains("viewer.FitToHeight()", preview, StringComparison.Ordinal); + Assert.Contains("viewer.Zoom = Math.Min(widthZoom, heightZoom)", preview, StringComparison.Ordinal); Assert.DoesNotContain("new DataGrid", preview, StringComparison.Ordinal); Assert.DoesNotContain("ItemsSource = snapshot.Rows", preview, StringComparison.Ordinal); @@ -94,6 +99,11 @@ public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); var layout = NativeFatP4DReportAdapter.Build(snapshot, draft: true); var bytes = IoFatPdfReportService.GenerateLayout(layout, snapshot.IedName, snapshot.Rows[0].IecTelegram); + var reportText = layout.Pages + .SelectMany(page => page.Commands) + .OfType() + .Select(command => command.Text) + .ToArray(); Assert.Equal("52_ACB1 Status", snapshot.Rows[0].Signal); Assert.Equal("COMPLETE", snapshot.Rows[0].Result); @@ -101,6 +111,10 @@ public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild Assert.False(string.IsNullOrWhiteSpace(snapshot.Rows[0].Value1TimestampText)); Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); Assert.False(string.IsNullOrWhiteSpace(snapshot.Rows[0].Value2TimestampText)); + Assert.Contains("OK", reportText); + Assert.DoesNotContain("COMPLETE", reportText); + Assert.Contains("ARSAS", reportText); + Assert.DoesNotContain(reportText, text => text.Contains("COMTRADE", StringComparison.OrdinalIgnoreCase)); Assert.True(layout.Pages.Count >= 2); Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "TESTED BY"); Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "WITNESSED BY"); @@ -109,6 +123,33 @@ public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild Assert.Equal("%PDF-1.4", Encoding.ASCII.GetString(bytes, 0, 8)); } + [Fact] + public void P4D_ReportUsesSharedSignalNamingAndCustomerFacingCopy() + { + var snapshot = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); + var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); + var finalization = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportFinalization.cs")); + var branding = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportBranding.cs")); + + Assert.Contains("IoFatSignalDisplayNameFormatter.Format(point.SignalName, point.IecReference)", snapshot, StringComparison.Ordinal); + Assert.Contains("NativeFatReportBranding.AddLogo", adapter, StringComparison.Ordinal); + Assert.Contains("NativeFatReportBranding.AddLogo", finalization, StringComparison.Ordinal); + Assert.Contains("\"ARSAS\"", branding, StringComparison.Ordinal); + Assert.Contains("return value.Equals(\"COMPLETE\", StringComparison.OrdinalIgnoreCase) ? \"OK\" : value;", adapter, StringComparison.Ordinal); + + foreach (var internalCopy in new[] + { + "Canonical Explorer snapshot", + "Immutable Engineering FAT snapshot", + "Final acceptance record for the immutable", + "blank sign-off fields are intentionally not prefilled" + }) + { + Assert.DoesNotContain(internalCopy, adapter, StringComparison.Ordinal); + Assert.DoesNotContain(internalCopy, finalization, StringComparison.Ordinal); + } + } + [Fact] public void P4D_ReportAdapterLocksExactNineColumnP4CContract() { From a15cba69fd9fb023b873aa112a9e26c3fbf09f64 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 10:07:28 +0700 Subject: [PATCH 117/158] fix(fat): polish report preview presentation --- MainWindow.NativeFatPrintPreview.cs | 77 ++++++-- Services/IoTesting/IoFatNativePdfWriter.cs | 59 +++++- .../IoFatReportPreviewDocumentBuilder.cs | 39 ++++ .../NativeFatCanonicalEvidenceOverlay.cs | 10 +- .../NativeFatPrintPreviewSnapshot.cs | 7 +- Services/IoTesting/NativeFatReportImage.cs | 178 ++++++++++++++++++ 6 files changed, 351 insertions(+), 19 deletions(-) create mode 100644 Services/IoTesting/NativeFatReportImage.cs diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs index ae4087676..7f9df2e39 100644 --- a/MainWindow.NativeFatPrintPreview.cs +++ b/MainWindow.NativeFatPrintPreview.cs @@ -24,6 +24,7 @@ private enum NativePreviewLucideIcon ChevronLeft, ChevronRight, RefreshCw, + ImagePlus, Save } @@ -56,7 +57,9 @@ private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) ArgumentNullException.ThrowIfNull(snapshot); var currentSnapshot = snapshot; + var currentLogo = NativeFatReportLogoService.TryLoadDefault(); var currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true); + currentLayout = NativeFatReportLogoService.Apply(currentLayout, currentLogo); var document = IoFatReportPreviewDocumentBuilder.Render(currentLayout); var preview = new Window @@ -152,6 +155,21 @@ void UpdateViewerState() zoomText.Text = $"{viewer.Zoom:0}%"; } + void RenderCurrentLayout() + { + currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true); + currentLayout = NativeFatReportLogoService.Apply(currentLayout, currentLogo); + viewer.Document = IoFatReportPreviewDocumentBuilder.Render(currentLayout); + summary.Text = NativeFatPreviewSummary(currentSnapshot); + preview.Dispatcher.BeginInvoke( + () => + { + FitNativeReportPage(viewer); + UpdateViewerState(); + }, + DispatcherPriority.Background); + } + Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) { var button = new Button @@ -192,19 +210,53 @@ Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) currentSnapshot = NativeFatPrintPreviewSnapshot.Capture( device, GetNativeFatSession(device.DeviceId)); - currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true); - viewer.Document = IoFatReportPreviewDocumentBuilder.Render(currentLayout); - summary.Text = NativeFatPreviewSummary(currentSnapshot); - preview.Dispatcher.BeginInvoke( - () => - { - FitNativeReportPage(viewer); - UpdateViewerState(); - }, - DispatcherPriority.Background); + RenderCurrentLayout(); SetStatus($"FAT · Print Preview refreshed from {currentSnapshot.IedName} evidence"); })); + var addLogoButton = new Button + { + Height = 30, + MinWidth = 92, + Padding = new Thickness(9, 0, 10, 0), + Margin = new Thickness(8, 0, 2, 0), + Style = TryFindResource("SoftButton") as Style, + ToolTip = "Replace the report logo for this Preview and its Save PDF output.", + Cursor = Cursors.Hand, + Content = BuildNativePreviewLabeledContent(NativePreviewLucideIcon.ImagePlus, "Add Logo") + }; + addLogoButton.Click += (_, _) => + { + var dialog = new OpenFileDialog + { + Title = "Add Report Logo", + Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg|PNG image (*.png)|*.png|JPEG image (*.jpg;*.jpeg)|*.jpg;*.jpeg", + Multiselect = false, + CheckFileExists = true + }; + + if (dialog.ShowDialog(preview) != true) + return; + + try + { + currentLogo = NativeFatReportLogoService.LoadFromFile(dialog.FileName); + RenderCurrentLayout(); + SetStatus($"FAT · report logo added · {System.IO.Path.GetFileName(dialog.FileName)}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException or InvalidOperationException) + { + SetStatus($"FAT · report logo could not be loaded · {ex.Message}"); + MessageBox.Show( + preview, + ex.Message, + "Add Logo", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + }; + actions.Children.Add(addLogoButton); + var savePdfButton = new Button { Height = 30, @@ -256,7 +308,7 @@ private void CommitNativeFatEvidenceEdits() } private static string NativeFatPreviewSummary(NativeFatPrintPreviewSnapshot snapshot) - => $"{snapshot.IedName} · {snapshot.Rows.Count} row(s) · {snapshot.ProgressText} · captured {snapshot.CapturedAt:yyyy-MM-dd HH:mm:ss zzz}"; + => $"{snapshot.IedName} · {snapshot.Rows.Count} row(s) · {snapshot.ProgressText} · captured {snapshot.CapturedAt:dd-MM-yyyy}"; private static FrameworkElement BuildNativePreviewLabeledContent(NativePreviewLucideIcon icon, string label) { @@ -290,6 +342,7 @@ private static Viewbox BuildNativePreviewLucideIcon(NativePreviewLucideIcon icon NativePreviewLucideIcon.ChevronLeft => "M15,18 L9,12 L15,6", NativePreviewLucideIcon.ChevronRight => "M9,18 L15,12 L9,6", NativePreviewLucideIcon.RefreshCw => "M3,12 A9,9 0 0 1 12,3 A9.75,9.75 0 0 1 18.74,5.74 L21,8 M21,3 L21,8 L16,8 M21,12 A9,9 0 0 1 12,21 A9.75,9.75 0 0 1 5.26,18.26 L3,16 M8,16 L3,16 L3,21", + NativePreviewLucideIcon.ImagePlus => "M3,5 L15,5 L15,19 L3,19 Z M5.5,15.5 L8.5,12.5 L10.8,14.8 L13,12.6 M17,6 L21,6 M19,4 L19,8 M7.5,9 A1,1 0 1 0 7.5,9.01", NativePreviewLucideIcon.Save => "M15.2,3 A2,2 0 0 1 16.6,3.6 L20.4,7.4 A2,2 0 0 1 21,8.8 L21,19 A2,2 0 0 1 19,21 L5,21 A2,2 0 0 1 3,19 L3,5 A2,2 0 0 1 5,3 Z M17,21 L17,14 A1,1 0 0 0 16,13 L8,13 A1,1 0 0 0 7,14 L7,21 M7,3 L7,7 A1,1 0 0 0 8,8 L15,8", _ => "M5,12 L19,12" }; @@ -396,4 +449,4 @@ private static string BuildNativeFatPdfFileName(string? iedName) safe = "IED"; return $"{safe}-FAT-Evidence.pdf"; } -} \ No newline at end of file +} diff --git a/Services/IoTesting/IoFatNativePdfWriter.cs b/Services/IoTesting/IoFatNativePdfWriter.cs index fbc5a0d5b..04ec11be1 100644 --- a/Services/IoTesting/IoFatNativePdfWriter.cs +++ b/Services/IoTesting/IoFatNativePdfWriter.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Globalization; +using System.IO.Compression; using System.Text; using ArIED61850Tester.Models.IoTesting; @@ -67,13 +68,28 @@ int AddObjectBytes(byte[] bytes) foreach (var page in layout.Pages) { + var pageImages = page.Commands.OfType().ToArray(); + var imageObjectIds = new List(pageImages.Length); + foreach (var image in pageImages) + imageObjectIds.Add(AddImageObject(image, AddObjectBytes)); + var content = BuildPageContent(page); var contentBytes = Encoding.ASCII.GetBytes(content); var contentId = AddObjectBytes(BuildStreamObject(contentBytes)); + + var resources = new StringBuilder() + .Append("/Font << /F1 ").Append(fontRegularId).Append(" 0 R /F2 ").Append(fontBoldId).Append(" 0 R >>"); + if (imageObjectIds.Count > 0) + { + resources.Append(" /XObject <<"); + for (var index = 0; index < imageObjectIds.Count; index++) + resources.Append(" /Im").Append(index + 1).Append(' ').Append(imageObjectIds[index]).Append(" 0 R"); + resources.Append(" >>"); + } + var pageId = AddObject( $"<< /Type /Page /Parent {pagesId} 0 R /MediaBox [0 0 {Number(page.Width)} {Number(page.Height)}] " + - $"/Resources << /Font << /F1 {fontRegularId} 0 R /F2 {fontBoldId} 0 R >> >> " + - $"/Contents {contentId} 0 R >>"); + $"/Resources << {resources} >> /Contents {contentId} 0 R >>"); pageIds.Add(pageId); } @@ -133,6 +149,31 @@ private static int AddEmbeddedTrueTypeFont( $"/Widths [{widths}] /FontDescriptor {descriptorId} 0 R /Encoding /WinAnsiEncoding >>"); } + private static int AddImageObject( + IoFatReportImageCommand image, + Func addBinaryObject) + { + if (image.PixelWidth <= 0 || image.PixelHeight <= 0 || + image.RgbPixels.Length != image.PixelWidth * image.PixelHeight * 3) + { + throw new InvalidOperationException("Report image RGB payload is invalid."); + } + + var compressed = Compress(image.RgbPixels); + var header = + $"<< /Type /XObject /Subtype /Image /Width {image.PixelWidth} /Height {image.PixelHeight} " + + $"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length {compressed.Length} >>\nstream\n"; + return addBinaryObject(BuildBinaryStreamObject(compressed, header)); + } + + private static byte[] Compress(byte[] payload) + { + using var output = new MemoryStream(); + using (var compressor = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true)) + compressor.Write(payload, 0, payload.Length); + return output.ToArray(); + } + private static byte[] BuildStreamObject(byte[] payload) => BuildBinaryStreamObject(payload, $"<< /Length {payload.Length.ToString(CultureInfo.InvariantCulture)} >>\nstream\n"); @@ -153,6 +194,7 @@ private static byte[] BuildBinaryStreamObject(byte[] payload, string header) private static string BuildPageContent(IoFatReportPagePlan page) { var output = new StringBuilder(32_000); + var imageIndex = 0; foreach (var command in page.Commands) { switch (command) @@ -169,6 +211,10 @@ private static string BuildPageContent(IoFatReportPagePlan page) case IoFatReportRectCommand rect: WriteRect(output, rect); break; + case IoFatReportImageCommand image: + imageIndex++; + WriteImage(output, image, imageIndex); + break; } } return output.ToString(); @@ -243,6 +289,15 @@ private static void WriteRoundRect(StringBuilder output, IoFatReportRectCommand .Append(command.StrokeThickness > 0d ? " c B\n" : " c f\n"); } + private static void WriteImage(StringBuilder output, IoFatReportImageCommand command, int imageIndex) + { + var y = command.TopY - command.Height; + output.Append("q ") + .Append(Number(command.Width)).Append(" 0 0 ").Append(Number(command.Height)).Append(' ') + .Append(Number(command.X)).Append(' ').Append(Number(y)).Append(" cm ") + .Append("/Im").Append(imageIndex).Append(" Do Q\n"); + } + private static string Fill(IoFatReportColor color) => $"{Channel(color.R)} {Channel(color.G)} {Channel(color.B)} rg"; diff --git a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs index fc8b7ce30..302c1fcbe 100644 --- a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs +++ b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs @@ -6,6 +6,7 @@ using System.Windows.Documents; using System.Windows.Markup; using System.Windows.Media; +using System.Windows.Media.Imaging; using System.Windows.Shapes; using ArIED61850Tester.Models.IoTesting; @@ -62,6 +63,9 @@ public static FixedDocument Render(IoFatReportLayoutPlan layout) case IoFatReportTextCommand text: AddText(fixedPage, pagePlan.Height, text); break; + case IoFatReportImageCommand image: + AddImage(fixedPage, pagePlan.Height, image); + break; } } @@ -158,6 +162,41 @@ private static void AddText(FixedPage page, double pageHeight, IoFatReportTextCo Math.Max(fontSize + 6d, fontSize * 1.65d)); } + private static void AddImage(FixedPage page, double pageHeight, IoFatReportImageCommand command) + { + if (command.PixelWidth <= 0 || command.PixelHeight <= 0 || + command.RgbPixels.Length != command.PixelWidth * command.PixelHeight * 3) + { + return; + } + + var bitmap = BitmapSource.Create( + command.PixelWidth, + command.PixelHeight, + 96d, + 96d, + PixelFormats.Rgb24, + null, + command.RgbPixels, + command.PixelWidth * 3); + bitmap.Freeze(); + + var image = new Image + { + Source = bitmap, + Stretch = Stretch.Uniform, + SnapsToDevicePixels = true + }; + + Add( + page, + image, + command.X * DipPerPdfPoint, + (pageHeight - command.TopY) * DipPerPdfPoint, + command.Width * DipPerPdfPoint, + command.Height * DipPerPdfPoint); + } + private static void Add(FixedPage page, UIElement element, double x, double y, double width, double height) { element.SetValue(FrameworkElement.WidthProperty, width); diff --git a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs index b495fa634..a22a87416 100644 --- a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs +++ b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs @@ -363,9 +363,13 @@ internal static string NormalizeTelegram(string? iecTelegram) private static string ResolveResult(NativeFatEvidenceSlotState slot) { - if (!string.IsNullOrWhiteSpace(slot.Result)) - return slot.Result.Trim(); - return HasValue1(slot) && HasValue2(slot) ? "COMPLETE" : string.Empty; + var result = !string.IsNullOrWhiteSpace(slot.Result) + ? slot.Result.Trim() + : HasValue1(slot) && HasValue2(slot) ? "COMPLETE" : string.Empty; + + // COMPLETE remains the native/raw evidence state. Customer/operator-facing reads + // present the completed state as OK; ReadRaw and persisted slot.Result stay untouched. + return result.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase) ? "OK" : result; } private static string StripDisplayTimestamp(string value) diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index 96197e6b3..055efa9f9 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -63,7 +63,10 @@ public static NativeFatPrintPreviewSnapshot Capture( var value2 = NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2); var capture1 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); var capture2 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value2); - var result = NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result); + var rawResult = NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Result); + var result = !string.IsNullOrWhiteSpace(rawResult) + ? rawResult.Trim() + : HasEvidence(value1) && HasEvidence(value2) ? "COMPLETE" : string.Empty; var displaySignal = IoFatSignalDisplayNameFormatter.Format(point.SignalName, point.IecReference); return new NativeFatPrintPreviewRow( @@ -103,4 +106,4 @@ private static string Copy(string? value) private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); -} \ No newline at end of file +} diff --git a/Services/IoTesting/NativeFatReportImage.cs b/Services/IoTesting/NativeFatReportImage.cs new file mode 100644 index 000000000..ff326b975 --- /dev/null +++ b/Services/IoTesting/NativeFatReportImage.cs @@ -0,0 +1,178 @@ +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Raster image command shared by the WPF report preview and native PDF writer. +/// Pixels are stored as opaque RGB so the exact same decoded image is rendered by both paths. +/// +internal sealed record IoFatReportImageCommand( + double X, + double TopY, + double Width, + double Height, + int PixelWidth, + int PixelHeight, + byte[] RgbPixels) : IoFatReportCommand; + +internal sealed record NativeFatReportLogo( + int PixelWidth, + int PixelHeight, + byte[] RgbPixels, + string SourceName); + +/// +/// Native FAT report logo authority. The default mark is the real packaged ARSAS app icon. +/// Custom logos are decoded once and stored in the immutable report command stream, so Preview +/// and Save PDF never depend on the source file after selection. +/// +internal static class NativeFatReportLogoService +{ + private const int MaxPixelDimension = 512; + private const double NativeLogoX = 710d; + private const double NativeLogoTop = 582d; + private const double NativeLogoSize = 22d; + + private static readonly string[] DefaultLogoUris = + [ + "pack://application:,,,/ARSAS;component/Assets/app-icon-256.png", + "pack://application:,,,/ARSAS;component/Assets/app-icon.png" + ]; + + public static NativeFatReportLogo? TryLoadDefault() + { + foreach (var uriText in DefaultLogoUris) + { + try + { + var resource = Application.GetResourceStream(new Uri(uriText, UriKind.Absolute)); + if (resource?.Stream == null) + continue; + using (resource.Stream) + return Decode(resource.Stream, uriText); + } + catch (IOException) + { + } + catch (InvalidOperationException) + { + } + } + + return null; + } + + public static NativeFatReportLogo LoadFromFile(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Logo file path is required.", nameof(path)); + + using var stream = File.OpenRead(path); + return Decode(stream, Path.GetFileName(path)); + } + + public static IoFatReportLayoutPlan Apply(IoFatReportLayoutPlan layout, NativeFatReportLogo? logo) + { + ArgumentNullException.ThrowIfNull(layout); + + var pages = layout.Pages + .Select(page => + { + var commands = new List(page.Commands.Count + 1); + foreach (var command in page.Commands) + { + // Retire only the synthetic blue-square + "A" part of the old mark. + // The adjacent ARSAS wordmark remains as text; the icon itself comes from + // the real packaged asset (or the operator-selected logo). + if (IsSyntheticIconCommand(command)) + continue; + commands.Add(command); + } + + if (logo != null) + { + commands.Add(new IoFatReportImageCommand( + NativeLogoX, + NativeLogoTop, + NativeLogoSize, + NativeLogoSize, + logo.PixelWidth, + logo.PixelHeight, + logo.RgbPixels)); + } + + return new IoFatReportPagePlan(page.PageNumber, page.Width, page.Height, commands.ToArray()); + }) + .ToArray(); + + return new IoFatReportLayoutPlan(layout.ProjectId, layout.CreatedAt, layout.Draft, pages); + } + + private static bool IsSyntheticIconCommand(IoFatReportCommand command) + { + if (command is IoFatReportRectCommand rect) + { + return Near(rect.X, NativeLogoX) && + Near(rect.TopY, NativeLogoTop) && + Near(rect.Width, NativeLogoSize) && + Near(rect.Height, NativeLogoSize); + } + + if (command is IoFatReportTextCommand text) + { + return string.Equals(text.Text, "A", StringComparison.Ordinal) && + Near(text.X, NativeLogoX + 5.2d) && + Near(text.BaselineY, NativeLogoTop - 15.2d); + } + + return false; + } + + private static NativeFatReportLogo Decode(Stream stream, string sourceName) + { + var decoder = BitmapDecoder.Create( + stream, + BitmapCreateOptions.PreservePixelFormat, + BitmapCacheOption.OnLoad); + BitmapSource source = decoder.Frames[0]; + + var largest = Math.Max(source.PixelWidth, source.PixelHeight); + if (largest > MaxPixelDimension) + { + var scale = MaxPixelDimension / (double)largest; + source = new TransformedBitmap(source, new ScaleTransform(scale, scale)); + } + + var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0d); + var width = converted.PixelWidth; + var height = converted.PixelHeight; + var bgraStride = checked(width * 4); + var bgra = new byte[checked(bgraStride * height)]; + converted.CopyPixels(bgra, bgraStride, 0); + + var rgb = new byte[checked(width * height * 3)]; + for (var sourceOffset = 0, targetOffset = 0; sourceOffset < bgra.Length; sourceOffset += 4, targetOffset += 3) + { + var blue = bgra[sourceOffset]; + var green = bgra[sourceOffset + 1]; + var red = bgra[sourceOffset + 2]; + var alpha = bgra[sourceOffset + 3]; + + // PDF image XObjects here are RGB-only. Composite transparency onto the white + // report page so transparent PNG logos remain visually correct in both renderers. + rgb[targetOffset] = CompositeOnWhite(red, alpha); + rgb[targetOffset + 1] = CompositeOnWhite(green, alpha); + rgb[targetOffset + 2] = CompositeOnWhite(blue, alpha); + } + + return new NativeFatReportLogo(width, height, rgb, sourceName); + } + + private static byte CompositeOnWhite(byte channel, byte alpha) + => (byte)((channel * alpha + 255 * (255 - alpha) + 127) / 255); + + private static bool Near(double left, double right) + => Math.Abs(left - right) < 0.01d; +} From 8669135c36aa7d5450d2fe5b770b6631d96fe23d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 10:10:12 +0700 Subject: [PATCH 118/158] fix(fat): compile report logo image pipeline --- Services/IoTesting/NativeFatReportImage.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/NativeFatReportImage.cs b/Services/IoTesting/NativeFatReportImage.cs index ff326b975..cf6af7650 100644 --- a/Services/IoTesting/NativeFatReportImage.cs +++ b/Services/IoTesting/NativeFatReportImage.cs @@ -153,7 +153,8 @@ private static NativeFatReportLogo Decode(Stream stream, string sourceName) converted.CopyPixels(bgra, bgraStride, 0); var rgb = new byte[checked(width * height * 3)]; - for (var sourceOffset = 0, targetOffset = 0; sourceOffset < bgra.Length; sourceOffset += 4, targetOffset += 3) + var targetOffset = 0; + for (var sourceOffset = 0; sourceOffset < bgra.Length; sourceOffset += 4, targetOffset += 3) { var blue = bgra[sourceOffset]; var green = bgra[sourceOffset + 1]; From 7021aa3abcbc7f6d6de4e15f5294ce589f5ae3e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 10:37:48 +0700 Subject: [PATCH 119/158] feat(fat): add native COMTRADE and time sync diagnostics --- MainWindow.NativeFatDiagnostics.cs | 367 ++++++++++++++++++ MainWindow.ProductionFatTab.cs | 8 +- .../IoTesting/NativeFatDiagnosticsService.cs | 284 ++++++++++++++ .../NativeFatDiagnosticsRegressionTests.cs | 221 +++++++++++ 4 files changed, 879 insertions(+), 1 deletion(-) create mode 100644 MainWindow.NativeFatDiagnostics.cs create mode 100644 Services/IoTesting/NativeFatDiagnosticsService.cs create mode 100644 tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs diff --git a/MainWindow.NativeFatDiagnostics.cs b/MainWindow.NativeFatDiagnostics.cs new file mode 100644 index 000000000..29295ff5e --- /dev/null +++ b/MainWindow.NativeFatDiagnostics.cs @@ -0,0 +1,367 @@ +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private Button? _nativeFatComtradeButton; + private Button? _nativeFatTimeSyncButton; + private CancellationTokenSource? _nativeFatComtradeDiscoveryCts; + private long _nativeFatComtradeDiscoveryGeneration; + private DispatcherTimer? _nativeFatDiagnosticRefreshTimer; + + private void InstallNativeFatDiagnosticButtons() + { + if (_nativeFatPrintPreviewButton?.Parent is not StackPanel actionPanel) + return; + + if (_nativeFatComtradeButton != null && actionPanel.Children.Contains(_nativeFatComtradeButton)) + return; + + var printIndex = actionPanel.Children.IndexOf(_nativeFatPrintPreviewButton); + if (printIndex < 0) + return; + + _nativeFatComtradeButton = new Button + { + Content = "COMTRADE —", + MinWidth = 112, + Padding = new Thickness(12, 6, 12, 6), + Margin = new Thickness(0, 0, 8, 0), + Style = TryFindResource("SoftButton") as Style, + IsEnabled = false, + ToolTip = "Actual IEC 61850 FileDirectory evidence for the selected IED." + }; + _nativeFatComtradeButton.Click += NativeFatComtradeButton_Click; + + _nativeFatTimeSyncButton = new Button + { + Content = "Time Sync Review", + MinWidth = 116, + Padding = new Thickness(12, 6, 12, 6), + Margin = new Thickness(0, 0, 8, 0), + Style = TryFindResource("SoftButton") as Style, + IsEnabled = false, + ToolTip = "Device-side time synchronization evidence. SNTP activity alone never grants OK." + }; + _nativeFatTimeSyncButton.Click += NativeFatTimeSyncButton_Click; + + actionPanel.Children.Insert(printIndex, _nativeFatComtradeButton); + actionPanel.Children.Insert(printIndex + 1, _nativeFatTimeSyncButton); + } + + private void BindNativeFatDiagnostics(Iec61850MonitorDevice? device) + { + CancelNativeFatComtradeDiscovery(); + + if (_nativeFatComtradeButton == null || _nativeFatTimeSyncButton == null) + return; + + if (device == null) + { + _nativeFatComtradeButton.Content = "COMTRADE —"; + _nativeFatComtradeButton.IsEnabled = false; + _nativeFatComtradeButton.ToolTip = "Select a connected Engineering IED to inspect its IEC 61850 file store."; + _nativeFatTimeSyncButton.Content = "Time Sync Review"; + _nativeFatTimeSyncButton.IsEnabled = false; + _nativeFatTimeSyncButton.ToolTip = "Select an Engineering IED to inspect device-side time evidence."; + StopNativeFatDiagnosticRefreshTimer(); + return; + } + + _nativeFatTimeSyncButton.IsEnabled = device.Points.Count > 0; + RefreshNativeFatTimeSyncButton(device); + StartNativeFatDiagnosticRefreshTimer(); + + if (!device.IsConnected || string.IsNullOrWhiteSpace(device.IpAddress)) + { + _nativeFatComtradeButton.Content = "COMTRADE —"; + _nativeFatComtradeButton.IsEnabled = false; + _nativeFatComtradeButton.ToolTip = "FileDirectory evidence is unavailable while the selected IED is disconnected."; + return; + } + + _nativeFatComtradeButton.IsEnabled = true; + BeginNativeFatComtradeDiscovery(device); + } + + private void BeginNativeFatComtradeDiscovery(Iec61850MonitorDevice device) + { + CancelNativeFatComtradeDiscovery(); + var generation = Interlocked.Increment(ref _nativeFatComtradeDiscoveryGeneration); + var cts = new CancellationTokenSource(); + _nativeFatComtradeDiscoveryCts = cts; + _ = DiscoverNativeFatComtradeAsync(device, generation, cts.Token); + } + + private async Task DiscoverNativeFatComtradeAsync( + Iec61850MonitorDevice device, + long generation, + CancellationToken cancellationToken) + { + if (_nativeFatComtradeButton == null) + return; + + _nativeFatComtradeButton.Content = "COMTRADE …"; + _nativeFatComtradeButton.ToolTip = "Reading the selected IED's IEC 61850 FileDirectory catalog…"; + + try + { + await using var client = new FaultRecordTransferClient(); + await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken); + var catalog = await client.DiscoverAsync(remoteDirectory: null, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + if (generation != _nativeFatComtradeDiscoveryGeneration || + !string.Equals(SelectedDevice?.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase) || + _nativeFatComtradeButton == null) + { + return; + } + + var fileCount = NativeFatComtradeDiagnosticService.CountDetectedFiles(catalog.Records); + _nativeFatComtradeButton.Content = $"COMTRADE {fileCount} Files"; + _nativeFatComtradeButton.ToolTip = + $"IEC 61850 FileDirectory: {fileCount:N0} actual file(s) across {catalog.Records.Count:N0} discovered fault record(s). " + + "Click to open the existing COMTRADE / fault-record file workflow."; + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or TimeoutException) + { + if (generation != _nativeFatComtradeDiscoveryGeneration || _nativeFatComtradeButton == null) + return; + + _nativeFatComtradeButton.Content = "COMTRADE —"; + _nativeFatComtradeButton.ToolTip = + $"IEC 61850 FileDirectory could not be verified: {ex.Message}\nNo COMTRADE count is fabricated."; + } + } + + private void NativeFatComtradeButton_Click(object sender, RoutedEventArgs e) + { + var device = SelectedDevice; + if (device == null || !device.IsConnected || string.IsNullOrWhiteSpace(device.IpAddress)) + return; + + CancelNativeFatComtradeDiscovery(); + var window = new FaultRecordWindow(device.Name, device.IpAddress, device.Port) + { + Owner = this + }; + window.Closed += (_, _) => + { + if (string.Equals(SelectedDevice?.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase) && + device.IsConnected) + { + BeginNativeFatComtradeDiscovery(device); + } + }; + window.Show(); + } + + private void NativeFatTimeSyncButton_Click(object sender, RoutedEventArgs e) + { + var device = SelectedDevice; + if (device == null) + return; + + var diagnostic = NativeFatTimeSyncDiagnosticService.Evaluate(device, DateTimeOffset.UtcNow); + var text = BuildNativeFatTimeSyncDiagnosticText(device, diagnostic); + var body = new TextBox + { + Text = text, + IsReadOnly = true, + IsReadOnlyCaretVisible = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + FontFamily = new FontFamily("Consolas"), + FontSize = 12.2, + Padding = new Thickness(16), + BorderThickness = new Thickness(0), + Background = TryFindResource("CardBackground") as Brush ?? Brushes.White, + Foreground = TryFindResource("Ink") as Brush ?? Brushes.Black + }; + + var window = new Window + { + Title = $"Time Synchronization · {device.Name}", + Owner = this, + Width = 760, + Height = 640, + MinWidth = 620, + MinHeight = 480, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = body + }; + window.Show(); + } + + private string BuildNativeFatTimeSyncDiagnosticText( + Iec61850MonitorDevice device, + NativeFatTimeSyncDiagnosticResult diagnostic) + { + var snapshot = ClockSyncSnapshot; + var builder = new StringBuilder(); + builder.AppendLine($"IED : {device.Name}"); + builder.AppendLine($"Endpoint : {device.IpAddress}:{device.Port}"); + builder.AppendLine($"ARSAS verdict : {diagnostic.Verdict}"); + builder.AppendLine($"Reason : {diagnostic.Summary}"); + builder.AppendLine(); + builder.AppendLine("DEVICE-SIDE AUTHORITY"); + builder.AppendLine($"LTMS present : {diagnostic.LtmsPresent}"); + builder.AppendLine($"LTMS trusted : {diagnostic.LtmsTrusted}"); + builder.AppendLine($"Fresh IEC stamps : {diagnostic.FreshPrimaryTimestampCount}"); + builder.AppendLine($"Allowed delta : ±{NativeFatTimeSyncDiagnosticService.MaximumTrustedClockDelta.TotalSeconds:0} s"); + builder.AppendLine($"Negative sync flag: {diagnostic.ExplicitNegativeSyncStatus}"); + + if (diagnostic.PrimaryEvidence.Count == 0) + { + builder.AppendLine(" — no authoritative LTMS/fresh timestamp evidence in the current canonical live rows"); + } + else + { + foreach (var evidence in diagnostic.PrimaryEvidence) + { + builder.AppendLine( + $" [{evidence.Role}] {(evidence.Trusted ? "trusted" : "review")} · {ValueOrDash(evidence.IecReference)} · " + + $"Q={ValueOrDash(evidence.Quality)} · T={ValueOrDash(evidence.DeviceTimestamp)}" + + (evidence.DeltaSeconds.HasValue ? $" · Δ={evidence.DeltaSeconds.Value:0.000}s" : string.Empty)); + } + } + + builder.AppendLine(); + builder.AppendLine("SECONDARY IED TELEMETRY"); + if (diagnostic.SecondaryTelemetry.Count == 0) + { + builder.AppendLine(" — Server 1 / Server 2 / Current server / TimeSynchrnz not exposed in current live rows"); + } + else + { + foreach (var evidence in diagnostic.SecondaryTelemetry) + { + builder.AppendLine( + $" {ValueOrDash(evidence.SignalName)} · {ValueOrDash(evidence.IecReference)} · " + + $"Value={ValueOrDash(evidence.Value)} · Q={ValueOrDash(evidence.Quality)}"); + } + } + + builder.AppendLine(); + builder.AppendLine("ARSAS SNTP PACKET TELEMETRY (SUPPORTING ONLY)"); + builder.AppendLine($"Enabled : {IsClockSyncEnabled}"); + builder.AppendLine($"Service state : {snapshot.State}"); + builder.AppendLine($"Transport : {snapshot.TransportMode}"); + builder.AppendLine($"Binding : {snapshot.Binding?.Summary ?? "—"}"); + builder.AppendLine($"Broadcasts : {snapshot.BroadcastCount}"); + builder.AppendLine($"Client requests : {snapshot.ClientRequestCount}"); + builder.AppendLine($"Replies sent : {snapshot.ReplyCount}"); + builder.AppendLine($"Selected IED request observed: {_clockSyncObservedClients.Contains(device.IpAddress)}"); + builder.AppendLine($"Selected IED reply sent : {_clockSyncRepliedClients.Contains(device.IpAddress)}"); + builder.AppendLine(); + builder.AppendLine("SNTP server activity, request/reply counters and a positive TimeSynchrnz value are supporting evidence only."); + builder.AppendLine("They never grant 'Time Sync OK' without the device-side LTMS/timestamp evidence evaluated above."); + return builder.ToString(); + } + + private void RefreshNativeFatTimeSyncButton(Iec61850MonitorDevice? device) + { + if (_nativeFatTimeSyncButton == null) + return; + + if (device == null || device.Points.Count == 0) + { + _nativeFatTimeSyncButton.Content = "Time Sync Review"; + _nativeFatTimeSyncButton.IsEnabled = false; + _nativeFatTimeSyncButton.ToolTip = "No canonical live rows are available for device-side time evidence."; + return; + } + + var diagnostic = NativeFatTimeSyncDiagnosticService.Evaluate(device, DateTimeOffset.UtcNow); + _nativeFatTimeSyncButton.IsEnabled = true; + _nativeFatTimeSyncButton.Content = diagnostic.IsSynchronized + ? "Time Sync OK" + : diagnostic.ExplicitNegativeSyncStatus + ? "Time Sync NOT OK" + : "Time Sync Review"; + _nativeFatTimeSyncButton.ToolTip = + $"{diagnostic.Verdict}: {diagnostic.Summary}\n" + + "Click for LTMS, IEC timestamp/quality, vendor time telemetry and ARSAS SNTP packet evidence."; + _nativeFatTimeSyncButton.Foreground = diagnostic.IsSynchronized + ? Brushes.SeaGreen + : diagnostic.ExplicitNegativeSyncStatus + ? Brushes.Firebrick + : TryFindResource("MutedInk") as Brush ?? Brushes.DarkSlateGray; + } + + private void StartNativeFatDiagnosticRefreshTimer() + { + _nativeFatDiagnosticRefreshTimer ??= CreateNativeFatDiagnosticRefreshTimer(); + if (!_nativeFatDiagnosticRefreshTimer.IsEnabled) + _nativeFatDiagnosticRefreshTimer.Start(); + } + + private DispatcherTimer CreateNativeFatDiagnosticRefreshTimer() + { + var timer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher) + { + Interval = TimeSpan.FromSeconds(3) + }; + timer.Tick += NativeFatDiagnosticRefreshTimer_Tick; + return timer; + } + + private void NativeFatDiagnosticRefreshTimer_Tick(object? sender, EventArgs e) + { + var device = SelectedDevice; + if (device == null || !string.Equals(_nativeFatBoundIedKey, device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + StopNativeFatDiagnosticRefreshTimer(); + return; + } + + // In-memory canonical-row evaluation only. This timer performs zero network reads. + RefreshNativeFatTimeSyncButton(device); + } + + private void StopNativeFatDiagnosticRefreshTimer() + => _nativeFatDiagnosticRefreshTimer?.Stop(); + + private void CancelNativeFatComtradeDiscovery() + { + Interlocked.Increment(ref _nativeFatComtradeDiscoveryGeneration); + _nativeFatComtradeDiscoveryCts?.Cancel(); + _nativeFatComtradeDiscoveryCts?.Dispose(); + _nativeFatComtradeDiscoveryCts = null; + } + + private void DisposeNativeFatDiagnostics() + { + CancelNativeFatComtradeDiscovery(); + if (_nativeFatDiagnosticRefreshTimer != null) + { + _nativeFatDiagnosticRefreshTimer.Tick -= NativeFatDiagnosticRefreshTimer_Tick; + _nativeFatDiagnosticRefreshTimer.Stop(); + _nativeFatDiagnosticRefreshTimer = null; + } + + if (_nativeFatComtradeButton != null) + _nativeFatComtradeButton.Click -= NativeFatComtradeButton_Click; + if (_nativeFatTimeSyncButton != null) + _nativeFatTimeSyncButton.Click -= NativeFatTimeSyncButton_Click; + _nativeFatComtradeButton = null; + _nativeFatTimeSyncButton = null; + } + + private static string ValueOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); +} diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index 222f9611c..2e6ac622f 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -78,7 +78,11 @@ private void ProductionFatInstallRetry_Tick(object? sender, EventArgs e) } private FrameworkElement BuildProductionFatPermanentHost() - => BuildNativeFatCanonicalWorkspace(); + { + var host = BuildNativeFatCanonicalWorkspace(); + InstallNativeFatDiagnosticButtons(); + return host; + } private void ProductionFat_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) { @@ -114,6 +118,7 @@ private void SynchronizeProductionFatSelectedIed() } BindNativeFatCanonicalRows(); + BindNativeFatDiagnostics(SelectedDevice); } // Compatibility host contract: the global Engineering IED Explorer and shared Command Dock remain authoritative. @@ -171,6 +176,7 @@ private void ProductionFat_MainWindowClosed(object? sender, EventArgs e) _productionFatWindow = null; if (_nativeFatCanonicalGrid != null) _nativeFatCanonicalGrid.CellEditEnding -= NativeFatCanonicalGrid_CellEditEnding; + DisposeNativeFatDiagnostics(); DisposeNativeFatArmCoordinator(); _nativeFatCanonicalGrid = null; _nativeFatIedText = null; diff --git a/Services/IoTesting/NativeFatDiagnosticsService.cs b/Services/IoTesting/NativeFatDiagnosticsService.cs new file mode 100644 index 000000000..eab92ee30 --- /dev/null +++ b/Services/IoTesting/NativeFatDiagnosticsService.cs @@ -0,0 +1,284 @@ +using System.Globalization; +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatTimeSyncPointEvidence( + string Role, + string SignalName, + string IecReference, + string Value, + string Quality, + string DeviceTimestamp, + double? DeltaSeconds, + bool Trusted); + +public sealed record NativeFatTimeSyncDiagnosticResult( + bool IsSynchronized, + string Verdict, + string Summary, + bool LtmsPresent, + bool LtmsTrusted, + int FreshPrimaryTimestampCount, + bool ExplicitNegativeSyncStatus, + IReadOnlyList PrimaryEvidence, + IReadOnlyList SecondaryTelemetry); + +/// +/// Read-only native FAT diagnostics over the canonical Engineering rows. +/// No network reads, polling changes, reconnects, or hidden acquisition are allowed here. +/// LTMS plus fresh IEC timestamp/quality evidence is authoritative. When LTMS is absent, +/// two independent fresh IEC timestamps are required as a conservative fallback. +/// Vendor sync flags (TimeSynchrnz/SyncSt/TimeSync) are secondary only: a positive flag +/// can never create an OK verdict, while an explicit negative flag fails closed. +/// +public static class NativeFatTimeSyncDiagnosticService +{ + public static readonly TimeSpan MaximumTrustedClockDelta = TimeSpan.FromSeconds(10); + + public static NativeFatTimeSyncDiagnosticResult Evaluate( + Iec61850MonitorDevice device, + DateTimeOffset nowUtc) + { + ArgumentNullException.ThrowIfNull(device); + nowUtc = nowUtc.ToUniversalTime(); + + var primary = new List(); + var secondary = new List(); + var ltmsPresent = false; + var ltmsTrusted = false; + var freshPrimaryCount = 0; + var explicitNegative = false; + + foreach (var point in device.Points) + { + var isLtms = IsLtms(point); + var isSecondary = IsSecondaryTelemetry(point); + var parsed = TryParseDeviceTimestamp(point.DeviceTimestamp); + var delta = parsed.HasValue + ? (nowUtc - parsed.Value.ToUniversalTime()).Duration() + : (TimeSpan?)null; + var goodQuality = Iec61850QualityPresentation.Classify(point.Quality) == Iec61850QualityPresentation.Good; + var timestampTrusted = parsed.HasValue && + delta.HasValue && + goodQuality && + delta.Value <= MaximumTrustedClockDelta; + + if (isLtms) + { + ltmsPresent = true; + var trusted = goodQuality && (timestampTrusted || IsUsable(point.Value)); + ltmsTrusted |= trusted; + primary.Add(ToEvidence("LTMS", point, delta, trusted)); + continue; + } + + if (isSecondary) + { + if (IsSyncStatusPoint(point) && NormalizeSyncBoolean(point.Value) == false) + explicitNegative = true; + secondary.Add(ToEvidence("Secondary", point, delta, timestampTrusted)); + continue; + } + + if (timestampTrusted) + { + freshPrimaryCount++; + primary.Add(ToEvidence("IEC timestamp", point, delta, true)); + } + } + + if (explicitNegative) + { + return new NativeFatTimeSyncDiagnosticResult( + false, + "NOT OK", + "Device-side synchronization telemetry explicitly reports not synchronized.", + ltmsPresent, + ltmsTrusted, + freshPrimaryCount, + true, + primary, + secondary); + } + + if (ltmsPresent) + { + if (ltmsTrusted && freshPrimaryCount >= 1) + { + return new NativeFatTimeSyncDiagnosticResult( + true, + "OK", + "LTMS evidence is present and cross-checked by a fresh good-quality IEC timestamp.", + true, + true, + freshPrimaryCount, + false, + primary, + secondary); + } + + return new NativeFatTimeSyncDiagnosticResult( + false, + "REVIEW", + ltmsTrusted + ? "LTMS is present, but no separate fresh good-quality IEC timestamp currently cross-checks it." + : "LTMS is present, but its live evidence is not currently trustworthy enough to prove synchronization.", + true, + ltmsTrusted, + freshPrimaryCount, + false, + primary, + secondary); + } + + if (freshPrimaryCount >= 2) + { + return new NativeFatTimeSyncDiagnosticResult( + true, + "OK", + "LTMS is not exposed; two or more independent fresh good-quality IEC timestamps agree with the ARSAS clock window.", + false, + false, + freshPrimaryCount, + false, + primary, + secondary); + } + + return new NativeFatTimeSyncDiagnosticResult( + false, + "REVIEW", + "Synchronization is not proven. LTMS is absent and fewer than two independent fresh good-quality IEC timestamps are available.", + false, + false, + freshPrimaryCount, + false, + primary, + secondary); + } + + private static NativeFatTimeSyncPointEvidence ToEvidence( + string role, + Iec61850MonitorPoint point, + TimeSpan? delta, + bool trusted) + => new( + role, + point.SignalName ?? string.Empty, + point.IecReference ?? string.Empty, + point.Value ?? string.Empty, + point.Quality ?? string.Empty, + point.DeviceTimestamp ?? string.Empty, + delta?.TotalSeconds, + trusted); + + private static bool IsLtms(Iec61850MonitorPoint point) + { + var reference = Normalize(point.IecReference); + var signal = Normalize(point.SignalName); + return reference.Contains("/ltms", StringComparison.Ordinal) || + reference.Contains(".ltms", StringComparison.Ordinal) || + reference.StartsWith("ltms", StringComparison.Ordinal) || + signal.Contains("ltms", StringComparison.Ordinal); + } + + private static bool IsSecondaryTelemetry(Iec61850MonitorPoint point) + { + var text = $"{Normalize(point.IecReference)} {Normalize(point.SignalName)}"; + return text.Contains("timesynchrnz", StringComparison.Ordinal) || + text.Contains("syncst", StringComparison.Ordinal) || + text.Contains("time sync", StringComparison.Ordinal) || + text.Contains("timesync", StringComparison.Ordinal) || + text.Contains("server 1", StringComparison.Ordinal) || + text.Contains("server1", StringComparison.Ordinal) || + text.Contains("server 2", StringComparison.Ordinal) || + text.Contains("server2", StringComparison.Ordinal) || + text.Contains("current server", StringComparison.Ordinal) || + text.Contains("currentserver", StringComparison.Ordinal); + } + + private static bool IsSyncStatusPoint(Iec61850MonitorPoint point) + { + var text = $"{Normalize(point.IecReference)} {Normalize(point.SignalName)}"; + return text.Contains("timesynchrnz", StringComparison.Ordinal) || + text.Contains("syncst", StringComparison.Ordinal) || + text.Contains("time sync", StringComparison.Ordinal) || + text.Contains("timesync", StringComparison.Ordinal); + } + + private static bool? NormalizeSyncBoolean(string? value) + { + var text = Normalize(value); + if (!IsUsable(text)) + return null; + + if (text is "true" or "1" or "1.0" or "on" or "active" or "synchronized" or "synchronised" or "synced" or "ok") + return true; + if (text is "false" or "0" or "0.0" or "off" or "inactive" or "not synchronized" or "not synchronised" or "unsynchronized" or "unsynchronised" or "not synced") + return false; + return null; + } + + private static DateTimeOffset? TryParseDeviceTimestamp(string? value) + { + var text = (value ?? string.Empty).Trim(); + if (!IsUsable(text)) + return null; + + if (DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + return parsed.ToUniversalTime(); + } + + return null; + } + + private static bool IsUsable(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length > 0 && text != "-" && text != "—" && + !text.Equals("unknown", StringComparison.OrdinalIgnoreCase) && + !text.Equals("pending", StringComparison.OrdinalIgnoreCase) && + !text.Contains("not probed", StringComparison.OrdinalIgnoreCase); + } + + private static string Normalize(string? value) + => (value ?? string.Empty).Trim().Replace('$', '.').ToLowerInvariant(); +} + +/// +/// Counts only files actually returned by the IEC 61850 fault-record/FileDirectory catalog. +/// No synthetic record/file count is permitted on the native FAT surface. +/// +public static class NativeFatComtradeDiagnosticService +{ + public static int CountDetectedFiles(IEnumerable? records) + { + if (records == null) + return 0; + + var files = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var record in records) + { + if (record?.Files == null) + continue; + + foreach (var file in record.Files) + { + var key = !string.IsNullOrWhiteSpace(file.RemotePath) + ? file.RemotePath.Trim() + : file.Name?.Trim(); + if (!string.IsNullOrWhiteSpace(key)) + files.Add(key); + } + } + + return files.Count; + } +} diff --git a/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs b/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs new file mode 100644 index 000000000..961beca9a --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs @@ -0,0 +1,221 @@ +using System.Globalization; +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatDiagnosticsRegressionTests +{ + [Fact] + public void ComtradeCount_UsesOnlyDistinctFilesReturnedByFileDirectoryCatalog() + { + var modified = new DateTimeOffset(2026, 9, 13, 3, 0, 0, TimeSpan.Zero); + var records = new[] + { + BuildRecord("FRA00027", modified, "FRA00027.cfg", "FRA00027.dat"), + BuildRecord("FRA00028", modified.AddMinutes(1), "FRA00028.cfg", "FRA00028.dat", "FRA00028.hdr"), + BuildRecord("FRA00028-copy", modified.AddMinutes(2), "FRA00028.cfg") + }; + + Assert.Equal(5, NativeFatComtradeDiagnosticService.CountDetectedFiles(records)); + Assert.Equal(0, NativeFatComtradeDiagnosticService.CountDetectedFiles(Array.Empty())); + } + + [Fact] + public void TimeSync_LtmsPlusFreshIndependentTimestamp_IsOk() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var device = BuildDevice( + Point("LTMS health", "IEDLD0/LTMS1.Health.stVal", "Good", "Good", now.AddSeconds(-1)), + Point("Breaker event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddSeconds(-2))); + + var result = NativeFatTimeSyncDiagnosticService.Evaluate(device, now); + + Assert.True(result.IsSynchronized); + Assert.Equal("OK", result.Verdict); + Assert.True(result.LtmsPresent); + Assert.True(result.LtmsTrusted); + Assert.Equal(1, result.FreshPrimaryTimestampCount); + } + + [Fact] + public void TimeSync_PositiveTimeSynchrnzWithoutPrimaryCrossCheck_NeverGrantsOk() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var device = BuildDevice( + Point("LTMS health", "IEDLD0/LTMS1.Health.stVal", "Good", "Good", now.AddSeconds(-1)), + Point("TimeSynchrnz", "IEDLD0/LLN0.TimeSynchrnz.stVal", "true", "Good", now.AddSeconds(-1))); + + var result = NativeFatTimeSyncDiagnosticService.Evaluate(device, now); + + Assert.False(result.IsSynchronized); + Assert.Equal("REVIEW", result.Verdict); + Assert.Equal(0, result.FreshPrimaryTimestampCount); + Assert.Contains(result.SecondaryTelemetry, item => item.IecReference.Contains("TimeSynchrnz", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void TimeSync_WithoutLtms_RequiresTwoIndependentFreshGoodTimestamps() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var onePoint = BuildDevice( + Point("Breaker event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddSeconds(-1))); + var twoPoints = BuildDevice( + Point("Breaker event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddSeconds(-1)), + Point("Disconnector event", "IEDLD0/XSWI1.Pos.stVal", "Closed [10]", "Good", now.AddSeconds(-3))); + + var insufficient = NativeFatTimeSyncDiagnosticService.Evaluate(onePoint, now); + var fallback = NativeFatTimeSyncDiagnosticService.Evaluate(twoPoints, now); + + Assert.False(insufficient.IsSynchronized); + Assert.Equal("REVIEW", insufficient.Verdict); + Assert.True(fallback.IsSynchronized); + Assert.Equal("OK", fallback.Verdict); + Assert.False(fallback.LtmsPresent); + Assert.Equal(2, fallback.FreshPrimaryTimestampCount); + } + + [Fact] + public void TimeSync_ExplicitNegativeVendorStatus_VetoesOtherwiseGoodPrimaryEvidence() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var device = BuildDevice( + Point("LTMS health", "IEDLD0/LTMS1.Health.stVal", "Good", "Good", now.AddSeconds(-1)), + Point("Breaker event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddSeconds(-1)), + Point("TimeSynchrnz", "IEDLD0/LLN0.TimeSynchrnz.stVal", "false", "Good", now.AddSeconds(-1))); + + var result = NativeFatTimeSyncDiagnosticService.Evaluate(device, now); + + Assert.False(result.IsSynchronized); + Assert.Equal("NOT OK", result.Verdict); + Assert.True(result.ExplicitNegativeSyncStatus); + } + + [Fact] + public void TimeSync_StaleOrBadQualityTimestamp_FailsClosed() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var device = BuildDevice( + Point("LTMS health", "IEDLD0/LTMS1.Health.stVal", "Good", "Good", now.AddSeconds(-1)), + Point("Stale event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddMinutes(-2)), + Point("Bad quality event", "IEDLD0/XSWI1.Pos.stVal", "Closed [10]", "Invalid", now.AddSeconds(-1))); + + var result = NativeFatTimeSyncDiagnosticService.Evaluate(device, now); + + Assert.False(result.IsSynchronized); + Assert.Equal("REVIEW", result.Verdict); + Assert.Equal(0, result.FreshPrimaryTimestampCount); + } + + [Fact] + public void NativeFatDiagnostics_ReusesExistingFileWorkflow_AndDoesNotStartSecondAcquisition() + { + var ui = File.ReadAllText(FindRepoFile("MainWindow.NativeFatDiagnostics.cs")); + var pivot = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + var service = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatDiagnosticsService.cs")); + + Assert.Contains("COMTRADE {fileCount} Files", ui, StringComparison.Ordinal); + Assert.Contains("new FaultRecordWindow(device.Name, device.IpAddress, device.Port)", ui, StringComparison.Ordinal); + Assert.Contains("Time Sync OK", ui, StringComparison.Ordinal); + Assert.Contains("SNTP server activity", ui, StringComparison.Ordinal); + Assert.Contains("NativeFatTimeSyncDiagnosticService.Evaluate", ui, StringComparison.Ordinal); + Assert.Contains("InstallNativeFatDiagnosticButtons()", pivot, StringComparison.Ordinal); + Assert.Contains("BindNativeFatDiagnostics(SelectedDevice)", pivot, StringComparison.Ordinal); + Assert.Contains("IEC 61850 FileDirectory", service, StringComparison.Ordinal); + + foreach (var forbidden in new[] + { + "ConnectAndDiscoverAsync", + "StartMonitoringAsync", + "PrepareIoTestIedForFatAsync", + "OpenDescribedSourcesAsync", + "IoFatEngineeringWorkspaceProjectionService" + }) + { + Assert.DoesNotContain(forbidden, ui, StringComparison.Ordinal); + Assert.DoesNotContain(forbidden, service, StringComparison.Ordinal); + } + } + + private static Iec61850MonitorDevice BuildDevice(params Iec61850MonitorPoint[] points) + { + var device = new Iec61850MonitorDevice + { + DeviceId = "native-fat-time-sync-test", + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + foreach (var point in points) + { + point.DeviceId = device.DeviceId; + point.DeviceName = device.Name; + point.IpAddress = device.IpAddress; + device.Points.Add(point); + } + return device; + } + + private static Iec61850MonitorPoint Point( + string name, + string reference, + string value, + string quality, + DateTimeOffset timestamp) + => new() + { + SignalName = name, + IecReference = reference, + Value = value, + Quality = quality, + DeviceTimestamp = timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture), + SourceMode = "IEC 61850 report", + Status = "Live" + }; + + private static Iec61850FaultRecordSet BuildRecord( + string baseName, + DateTimeOffset modified, + params string[] fileNames) + => new() + { + RecordId = baseName, + BaseName = baseName, + RemoteDirectory = string.Empty, + LastModifiedUtc = modified, + Completeness = "Detected", + Files = fileNames.Select(name => new Iec61850FaultRecordFile + { + Name = name, + RemotePath = name, + BaseName = Path.GetFileNameWithoutExtension(name), + Extension = Path.GetExtension(name), + LastModifiedUtc = modified, + SizeBytes = 1024 + }).ToArray() + }; + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From a11fe4148785c60ceeb2ad2ef8e8e584cb1d6045 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 10:43:26 +0700 Subject: [PATCH 120/158] test(fat): relax FileDirectory source guard --- tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs b/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs index 961beca9a..ad2c7a74b 100644 --- a/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs @@ -122,7 +122,7 @@ public void NativeFatDiagnostics_ReusesExistingFileWorkflow_AndDoesNotStartSecon Assert.Contains("NativeFatTimeSyncDiagnosticService.Evaluate", ui, StringComparison.Ordinal); Assert.Contains("InstallNativeFatDiagnosticButtons()", pivot, StringComparison.Ordinal); Assert.Contains("BindNativeFatDiagnostics(SelectedDevice)", pivot, StringComparison.Ordinal); - Assert.Contains("IEC 61850 FileDirectory", service, StringComparison.Ordinal); + Assert.Contains("FileDirectory", service, StringComparison.Ordinal); foreach (var forbidden in new[] { From 2a60928e4e5b2961ed5cefda65885cc9dba7831f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 11:12:58 +0700 Subject: [PATCH 121/158] fix(fat): pin evidence to recycled rows and flush on shutdown --- MainWindow.NativeFatFieldEvidenceFixes.cs | 163 +++++++++++++++++ .../IoTesting/NativeFatReportFinalization.cs | 11 +- Services/IoTesting/NativeFatReportImage.cs | 25 ++- .../NativeFatFieldEvidenceRegressionTests.cs | 171 ++++++++++++++++++ 4 files changed, 353 insertions(+), 17 deletions(-) create mode 100644 MainWindow.NativeFatFieldEvidenceFixes.cs create mode 100644 tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs diff --git a/MainWindow.NativeFatFieldEvidenceFixes.cs b/MainWindow.NativeFatFieldEvidenceFixes.cs new file mode 100644 index 000000000..ea7f944ed --- /dev/null +++ b/MainWindow.NativeFatFieldEvidenceFixes.cs @@ -0,0 +1,163 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Field hardening for native FAT evidence presentation/persistence. +/// Evidence identity remains IEDName + IEC Telegram; this layer only prevents recycled WPF +/// containers from showing stale evidence and guarantees the latest sparse evidence snapshot +/// is flushed before the native FAT persistence service is disposed on application close. +/// +public partial class MainWindow +{ + private bool _nativeFatFieldEvidenceHooksInstalled; + + [ModuleInitializer] + internal static void RegisterNativeFatFieldEvidenceHardening() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatFieldEvidence_MainWindowLoaded), + handledEventsToo: true); + + EventManager.RegisterClassHandler( + typeof(DataGridRow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatFieldEvidence_DataGridRowLoaded), + handledEventsToo: true); + } + + private static void NativeFatFieldEvidence_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatFieldEvidenceHooksInstalled) + return; + + window._nativeFatFieldEvidenceHooksInstalled = true; + window.Closing += window.NativeFatFieldEvidence_WindowClosing; + } + + private static void NativeFatFieldEvidence_DataGridRowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not DataGridRow row || + Window.GetWindow(row) is not MainWindow window || + window._nativeFatCanonicalGrid == null || + !ReferenceEquals(ItemsControl.ItemsControlFromItemContainer(row), window._nativeFatCanonicalGrid)) + { + return; + } + + if (row.Tag is not NativeFatEvidenceRecycleHookMarker) + { + row.Tag = NativeFatEvidenceRecycleHookMarker.Instance; + row.DataContextChanged += NativeFatFieldEvidence_RowDataContextChanged; + } + + window.RefreshNativeFatEvidenceRowAfterRecycle(row); + } + + private static void NativeFatFieldEvidence_RowDataContextChanged( + object sender, + DependencyPropertyChangedEventArgs e) + { + if (sender is not DataGridRow row || + Window.GetWindow(row) is not MainWindow window || + window._nativeFatCanonicalGrid == null || + !ReferenceEquals(ItemsControl.ItemsControlFromItemContainer(row), window._nativeFatCanonicalGrid)) + { + return; + } + + // Fail closed immediately: a recycled visual must never show evidence from its + // previous DataContext while WPF finishes assigning the new canonical row. + window.ClearNativeFatEvidenceRowVisual(row); + window.RefreshNativeFatEvidenceRowAfterRecycle(row); + } + + private void ClearNativeFatEvidenceRowVisual(DataGridRow row) + { + if (_nativeFatCanonicalGrid == null) + return; + + foreach (var column in _nativeFatCanonicalGrid.Columns.OfType()) + { + if (column.GetCellContent(row) is TextBlock textBlock) + textBlock.Text = string.Empty; + } + } + + private void RefreshNativeFatEvidenceRowAfterRecycle(DataGridRow row) + { + Dispatcher.BeginInvoke( + () => + { + if (_nativeFatCanonicalGrid == null || + !ReferenceEquals(ItemsControl.ItemsControlFromItemContainer(row), _nativeFatCanonicalGrid) || + row.Item is not Iec61850MonitorPoint point) + { + return; + } + + // Re-read by the current canonical point. ReadNativeFatEvidence ultimately + // resolves only IEDName + IEC Telegram; row index and SignalName never enter. + RefreshNativeFatEvidenceCells(point); + }, + DispatcherPriority.DataBind); + } + + private void NativeFatFieldEvidence_WindowClosing(object? sender, CancelEventArgs e) + { + CommitNativeFatEvidenceEdits(); + FlushNativeFatEvidenceBeforeShutdown(); + } + + private void FlushNativeFatEvidenceBeforeShutdown() + { + foreach (var device in Devices.ToArray()) + { + if (!_nativeFatSessionByIed.TryGetValue(device.DeviceId, out var cache)) + continue; + + var hasEvidence = false; + lock (cache.EvidenceByRow) + hasEvidence = cache.EvidenceByRow.Count > 0; + if (!hasEvidence) + continue; + + try + { + // SaveAsync snapshots the canonical rows synchronously before yielding and + // writes an atomic file whose path is derived from IEDName, not DeviceId. + // Closing waits for this small sparse snapshot so the subsequent Closed + // cleanup cannot cancel the only pending persistence operation. + _nativeFatEvidenceHydrationService + .SaveAsync(device, cache, CancellationToken.None) + .GetAwaiter() + .GetResult(); + + Trace.WriteLine( + $"[FAT field] evidence flush completed before shutdown; ied={device.Name}; deviceId={device.DeviceId}; rows={cache.EvidenceByRow.Count}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine( + $"[FAT field] evidence flush failed before shutdown for {device.Name}: {ex.Message}"); + } + } + } + + private sealed class NativeFatEvidenceRecycleHookMarker + { + public static NativeFatEvidenceRecycleHookMarker Instance { get; } = new(); + + private NativeFatEvidenceRecycleHookMarker() + { + } + } +} diff --git a/Services/IoTesting/NativeFatReportFinalization.cs b/Services/IoTesting/NativeFatReportFinalization.cs index 9fafcdcb8..63aa28796 100644 --- a/Services/IoTesting/NativeFatReportFinalization.cs +++ b/Services/IoTesting/NativeFatReportFinalization.cs @@ -59,12 +59,9 @@ private static IoFatReportPagePlan BuildSignOffPage( IoFatReportFontKind.Regular, 8.0d, Muted); Line(commands, Margin, 498d, PageWidth - Margin, 498d, Border, 0.8d); - Rect(commands, 590d, 568d, 222d, 64d, 4d, SoftBlue, Border, 0.7d); - Text(commands, 601d, 554d, 200d, "IED / REPORT SCOPE", IoFatReportFontKind.Bold, 5.9d, Muted); - Text(commands, 601d, 538d, 200d, Clean(snapshot.IedName), IoFatReportFontKind.Bold, 8.2d, Navy); - Text(commands, 601d, 523d, 200d, Clean(snapshot.DeviceId), IoFatReportFontKind.Mono, 5.8d, Blue); - Text(commands, 601d, 511d, 200d, "FOR FAT RECORD", IoFatReportFontKind.Regular, 5.8d, Muted); - + // Keep the upper-right header clear for the report logo. IED identity is already + // carried by the preceding evidence pages; duplicating a scope card here caused a + // visual collision and added no sign-off evidence. Text(commands, Margin, 470d, ContentWidth, "By signing below, the parties acknowledge the FAT execution and evidence recorded in the preceding pages.", IoFatReportFontKind.Regular, 7.2d, Ink); @@ -155,4 +152,4 @@ private static void Text( private static string Clean(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); -} \ No newline at end of file +} diff --git a/Services/IoTesting/NativeFatReportImage.cs b/Services/IoTesting/NativeFatReportImage.cs index cf6af7650..b7bfa6c55 100644 --- a/Services/IoTesting/NativeFatReportImage.cs +++ b/Services/IoTesting/NativeFatReportImage.cs @@ -32,7 +32,8 @@ internal static class NativeFatReportLogoService { private const int MaxPixelDimension = 512; private const double NativeLogoX = 710d; - private const double NativeLogoTop = 582d; + private const double LegacySyntheticLogoTop = 582d; + private const double NativeLogoTop = 576d; private const double NativeLogoSize = 22d; private static readonly string[] DefaultLogoUris = @@ -83,10 +84,10 @@ public static IoFatReportLayoutPlan Apply(IoFatReportLayoutPlan layout, NativeFa var commands = new List(page.Commands.Count + 1); foreach (var command in page.Commands) { - // Retire only the synthetic blue-square + "A" part of the old mark. - // The adjacent ARSAS wordmark remains as text; the icon itself comes from - // the real packaged asset (or the operator-selected logo). - if (IsSyntheticIconCommand(command)) + // The base layout still carries the legacy synthetic icon/wordmark so + // older non-image report paths remain structurally compatible. Native + // Preview/PDF replaces the complete legacy mark with the real app icon. + if (IsLegacyBrandingCommand(command)) continue; commands.Add(command); } @@ -110,21 +111,25 @@ public static IoFatReportLayoutPlan Apply(IoFatReportLayoutPlan layout, NativeFa return new IoFatReportLayoutPlan(layout.ProjectId, layout.CreatedAt, layout.Draft, pages); } - private static bool IsSyntheticIconCommand(IoFatReportCommand command) + private static bool IsLegacyBrandingCommand(IoFatReportCommand command) { if (command is IoFatReportRectCommand rect) { return Near(rect.X, NativeLogoX) && - Near(rect.TopY, NativeLogoTop) && + Near(rect.TopY, LegacySyntheticLogoTop) && Near(rect.Width, NativeLogoSize) && Near(rect.Height, NativeLogoSize); } if (command is IoFatReportTextCommand text) { - return string.Equals(text.Text, "A", StringComparison.Ordinal) && - Near(text.X, NativeLogoX + 5.2d) && - Near(text.BaselineY, NativeLogoTop - 15.2d); + var syntheticA = string.Equals(text.Text, "A", StringComparison.Ordinal) && + Near(text.X, NativeLogoX + 5.2d) && + Near(text.BaselineY, LegacySyntheticLogoTop - 15.2d); + var legacyWordmark = string.Equals(text.Text, "ARSAS", StringComparison.Ordinal) && + Near(text.X, NativeLogoX + 29d) && + Near(text.BaselineY, LegacySyntheticLogoTop - 15.4d); + return syntheticA || legacyWordmark; } return false; diff --git a/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs b/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs new file mode 100644 index 000000000..5dc349efb --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs @@ -0,0 +1,171 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatFieldEvidenceRegressionTests +{ + [Fact] + public async Task RestartWithNewRuntimeDeviceId_RestoresOnlyExactIedNameAndTelegram() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + + var before = Device("runtime-before"); + var cswiBefore = Point(before, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Open [01]"); + var sfBefore = Point(before, "SF62ndCB", "ADD/GGIO5.SF62ndCB.stVal", "false"); + before.Points.Add(cswiBefore); + before.Points.Add(sfBefore); + + var saved = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + saved, + cswiBefore, + NativeFatEvidenceField.Value1, + "Open [01]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Parse("2026-09-13T11:01:37.116+07:00")); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + saved, + cswiBefore, + NativeFatEvidenceField.Value2, + "Closed [10]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Parse("2026-09-13T11:01:51.329+07:00")); + await service.SaveAsync(before, saved); + + var after = Device("runtime-after"); + var sfAfter = Point(after, "SF62ndCB", "ADD/GGIO5.SF62ndCB.stVal", "false"); + var cswiAfter = Point(after, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Closed [10]"); + after.Points.Add(sfAfter); + after.Points.Add(cswiAfter); + + var hydration = await service.HydrateAsync(after); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); + + Assert.True(hydration.Succeeded); + Assert.True(hydration.SnapshotFound); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value2)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, sfAfter, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, sfAfter, NativeFatEvidenceField.Value2)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void RecycledGridRows_ClearStaleEvidenceAndRefreshFromCurrentCanonicalPoint() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatFieldEvidenceFixes.cs")); + + Assert.Contains("row.DataContextChanged += NativeFatFieldEvidence_RowDataContextChanged", source, StringComparison.Ordinal); + Assert.Contains("ClearNativeFatEvidenceRowVisual(row)", source, StringComparison.Ordinal); + Assert.Contains("row.Item is not Iec61850MonitorPoint point", source, StringComparison.Ordinal); + Assert.Contains("RefreshNativeFatEvidenceCells(point)", source, StringComparison.Ordinal); + Assert.Contains("IEDName + IEC Telegram", source, StringComparison.Ordinal); + Assert.DoesNotContain("SelectedIndex", source, StringComparison.Ordinal); + Assert.DoesNotContain("SignalName", source, StringComparison.Ordinal); + } + + [Fact] + public void ApplicationClosing_FlushesEvidenceBeforeClosedCleanupCanCancelDebounce() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatFieldEvidenceFixes.cs")); + var production = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + Assert.Contains("window.Closing += window.NativeFatFieldEvidence_WindowClosing", source, StringComparison.Ordinal); + Assert.Contains("FlushNativeFatEvidenceBeforeShutdown()", source, StringComparison.Ordinal); + Assert.Contains("SaveAsync(device, cache, CancellationToken.None)", source, StringComparison.Ordinal); + Assert.Contains("Closed += ProductionFat_MainWindowClosed", production, StringComparison.Ordinal); + Assert.Contains("DisposeNativeFatArmCoordinator()", production, StringComparison.Ordinal); + } + + [Fact] + public void ReportBranding_UsesIconOnly_LowersLogo_AndSignOffHasNoScopeCard() + { + var image = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportImage.cs")); + var finalization = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportFinalization.cs")); + + Assert.Contains("private const double NativeLogoTop = 576d", image, StringComparison.Ordinal); + Assert.Contains("legacyWordmark", image, StringComparison.Ordinal); + Assert.Contains("string.Equals(text.Text, \"ARSAS\"", image, StringComparison.Ordinal); + Assert.DoesNotContain("IED / REPORT SCOPE", finalization, StringComparison.Ordinal); + Assert.DoesNotContain("FOR FAT RECORD", finalization, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(string deviceId) + => new() + { + DeviceId = deviceId, + Name = "AA1EIF06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + Iec61850MonitorDevice device, + string signal, + string reference, + string value) + => new() + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + SignalName = signal, + IecReference = reference, + IecDataType = "DbPos", + Quality = "Good", + Status = "Live", + SourceMode = "IEC 61850 report", + Value = value, + DeviceTimestamp = "2026-09-13T11:01:51.329+07:00" + }; + + private static string TempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "arsas-native-fat-field-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + } + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From b6dc2f68810e7107d7991391970d8e05da9dadf8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 11:13:42 +0700 Subject: [PATCH 122/158] test(fat): tighten recycled-row field guard --- tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs b/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs index 5dc349efb..48c8b1348 100644 --- a/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs @@ -70,7 +70,7 @@ public void RecycledGridRows_ClearStaleEvidenceAndRefreshFromCurrentCanonicalPoi Assert.Contains("RefreshNativeFatEvidenceCells(point)", source, StringComparison.Ordinal); Assert.Contains("IEDName + IEC Telegram", source, StringComparison.Ordinal); Assert.DoesNotContain("SelectedIndex", source, StringComparison.Ordinal); - Assert.DoesNotContain("SignalName", source, StringComparison.Ordinal); + Assert.DoesNotContain("point.SignalName", source, StringComparison.Ordinal); } [Fact] From 9c407d902bb35e9908ca40a00956b5e125f531f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:09:14 +0700 Subject: [PATCH 123/158] fix(fat): bind evidence cells to canonical row identity --- MainWindow.NativeFatEvidenceBindingRuntime.cs | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 MainWindow.NativeFatEvidenceBindingRuntime.cs diff --git a/MainWindow.NativeFatEvidenceBindingRuntime.cs b/MainWindow.NativeFatEvidenceBindingRuntime.cs new file mode 100644 index 000000000..e0ddb815c --- /dev/null +++ b/MainWindow.NativeFatEvidenceBindingRuntime.cs @@ -0,0 +1,316 @@ +using System.Globalization; +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Evidence column whose text is a real WPF binding to the current row DataContext. +/// Recycling a DataGridRow therefore re-evaluates the evidence against the new canonical +/// Iec61850MonitorPoint instead of retaining imperative TextBlock.Text from the prior row. +/// +internal sealed class NativeFatEvidenceBindingColumn : DataGridColumn +{ + private readonly NativeFatEvidenceBindingConverter _converter; + + internal NativeFatEvidenceBindingColumn( + string header, + NativeFatEvidenceField field, + double width, + Func reader) + { + ArgumentNullException.ThrowIfNull(reader); + Header = header; + Field = field; + Width = new DataGridLength(width); + MinWidth = 78; + _converter = new NativeFatEvidenceBindingConverter(field, reader); + } + + internal NativeFatEvidenceField Field { get; } + + protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem) + { + var block = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + block.SetBinding(TextBlock.TextProperty, CreateBinding()); + return block; + } + + protected override FrameworkElement GenerateEditingElement(DataGridCell cell, object dataItem) + { + var editor = new TextBox + { + VerticalContentAlignment = VerticalAlignment.Center, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0) + }; + editor.SetBinding(TextBox.TextProperty, CreateBinding()); + return editor; + } + + internal void RefreshTarget(Iec61850MonitorPoint point) + { + if (GetCellContent(point) is TextBlock block) + { + block.GetBindingExpression(TextBlock.TextProperty)?.UpdateTarget(); + return; + } + + if (GetCellContent(point) is TextBox editor) + editor.GetBindingExpression(TextBox.TextProperty)?.UpdateTarget(); + } + + private Binding CreateBinding() + => new() + { + Path = new PropertyPath("."), + Mode = BindingMode.OneWay, + Converter = _converter + }; + + private sealed class NativeFatEvidenceBindingConverter : IValueConverter + { + private readonly NativeFatEvidenceField _field; + private readonly Func _reader; + + internal NativeFatEvidenceBindingConverter( + NativeFatEvidenceField field, + Func reader) + { + _field = field; + _reader = reader; + } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is Iec61850MonitorPoint point + ? _reader(point, _field) + : string.Empty; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } +} + +public partial class MainWindow +{ + private DataGrid? _nativeFatEvidenceBindingGrid; + private bool _nativeFatEvidenceBindingArmHooked; + private DispatcherTimer? _nativeFatEvidenceBindingHydrationTimer; + private bool _nativeFatEvidenceBindingHydrationWasActive; + + /// + /// Hooks editing/refresh behavior for the bound evidence columns. The canonical grid still + /// owns SelectedDevice.Points and keeps row virtualization/recycling enabled. + /// + private void InstallNativeFatEvidenceBindingRuntime() + { + var grid = _nativeFatCanonicalGrid; + if (grid == null) + return; + + if (!ReferenceEquals(_nativeFatEvidenceBindingGrid, grid)) + { + if (_nativeFatEvidenceBindingGrid != null) + { + _nativeFatEvidenceBindingGrid.BeginningEdit -= NativeFatEvidenceBinding_BeginningEdit; + _nativeFatEvidenceBindingGrid.CellEditEnding -= NativeFatEvidenceBinding_CellEditEnding; + } + + _nativeFatEvidenceBindingGrid = grid; + grid.BeginningEdit += NativeFatEvidenceBinding_BeginningEdit; + grid.CellEditEnding += NativeFatEvidenceBinding_CellEditEnding; + } + + if (!_nativeFatEvidenceBindingArmHooked) + { + _nativeFatArmCoordinator.EvidenceChanged += NativeFatEvidenceBinding_EvidenceChanged; + _nativeFatEvidenceBindingArmHooked = true; + } + } + + /// + /// Called after each selected-IED bind. Hydration changes the sparse cache rather than the + /// canonical row object, so this short-lived timer only refreshes binding targets while + /// hydration is active and performs one final refresh when hydration resolves. + /// + private void RefreshNativeFatEvidenceBindingRuntime() + { + RefreshAllNativeFatEvidenceBindingTargets(); + + var hydrating = IsNativeFatEvidenceBindingHydrating(); + _nativeFatEvidenceBindingHydrationWasActive = hydrating; + if (!hydrating) + { + _nativeFatEvidenceBindingHydrationTimer?.Stop(); + return; + } + + _nativeFatEvidenceBindingHydrationTimer ??= CreateNativeFatEvidenceBindingHydrationTimer(); + if (!_nativeFatEvidenceBindingHydrationTimer.IsEnabled) + _nativeFatEvidenceBindingHydrationTimer.Start(); + } + + private DispatcherTimer CreateNativeFatEvidenceBindingHydrationTimer() + { + var timer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher) + { + Interval = TimeSpan.FromMilliseconds(320) + }; + timer.Tick += NativeFatEvidenceBindingHydrationTimer_Tick; + return timer; + } + + private void NativeFatEvidenceBindingHydrationTimer_Tick(object? sender, EventArgs e) + { + var hydrating = IsNativeFatEvidenceBindingHydrating(); + if (hydrating) + { + _nativeFatEvidenceBindingHydrationWasActive = true; + RefreshAllNativeFatEvidenceBindingTargets(); + return; + } + + if (_nativeFatEvidenceBindingHydrationWasActive) + RefreshAllNativeFatEvidenceBindingTargets(); + + _nativeFatEvidenceBindingHydrationWasActive = false; + _nativeFatEvidenceBindingHydrationTimer?.Stop(); + } + + private bool IsNativeFatEvidenceBindingHydrating() + => !string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) && + _nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache) && + cache.IsEvidenceHydrating; + + private void NativeFatEvidenceBinding_EvidenceChanged(object? sender, NativeFatEvidenceChangedEventArgs e) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(() => NativeFatEvidenceBinding_EvidenceChanged(sender, e)); + return; + } + + if (!string.Equals(_nativeFatBoundIedKey, e.DeviceId, StringComparison.OrdinalIgnoreCase)) + return; + + RefreshNativeFatEvidenceBindingTargets(e.Point); + } + + private void RefreshNativeFatEvidenceBindingTargets(Iec61850MonitorPoint point) + { + if (_nativeFatCanonicalGrid == null) + return; + + foreach (var column in _nativeFatCanonicalGrid.Columns.OfType()) + column.RefreshTarget(point); + } + + private void RefreshAllNativeFatEvidenceBindingTargets() + { + if (_nativeFatCanonicalGrid == null) + return; + + foreach (var point in _nativeFatCanonicalGrid.Items.OfType()) + RefreshNativeFatEvidenceBindingTargets(point); + } + + private void NativeFatEvidenceBinding_BeginningEdit(object? sender, DataGridBeginningEditEventArgs e) + { + if (e.Column is not NativeFatEvidenceBindingColumn || string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + return; + + if (_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache) && cache.IsEvidenceHydrating) + e.Cancel = true; + } + + private void NativeFatEvidenceBinding_CellEditEnding(object? sender, DataGridCellEditEndingEventArgs e) + { + if (e.EditAction != DataGridEditAction.Commit || + e.Row.Item is not Iec61850MonitorPoint point || + e.Column is not NativeFatEvidenceBindingColumn evidenceColumn || + e.EditingElement is not TextBox editor || + string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + { + return; + } + + var cache = GetNativeFatSession(_nativeFatBoundIedKey); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, evidenceColumn.Field, editor.Text); + cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + ScheduleNativeFatEvidencePersist(_nativeFatBoundIedKey); + Dispatcher.BeginInvoke( + () => evidenceColumn.RefreshTarget(point), + DispatcherPriority.DataBind); + } + + /// + /// Flush sparse FAT evidence before the arm/persistence services are disposed. Snapshot + /// paths are IEDName-based and row identity remains IEDName + IEC Telegram. + /// + private void FlushNativeFatEvidenceBeforeShutdown() + { + foreach (var device in Devices.ToArray()) + { + if (!_nativeFatSessionByIed.TryGetValue(device.DeviceId, out var cache)) + continue; + + bool hasEvidence; + lock (cache.EvidenceByRow) + hasEvidence = cache.EvidenceByRow.Count > 0; + if (!hasEvidence) + continue; + + try + { + _nativeFatEvidenceHydrationService + .SaveAsync(device, cache, CancellationToken.None) + .GetAwaiter() + .GetResult(); + + Trace.WriteLine( + $"[FAT field] evidence flush completed before shutdown; ied={device.Name}; deviceId={device.DeviceId}; rows={cache.EvidenceByRow.Count}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine( + $"[FAT field] evidence flush failed before shutdown for {device.Name}: {ex.Message}"); + } + } + } + + private void DisposeNativeFatEvidenceBindingRuntime() + { + if (_nativeFatEvidenceBindingGrid != null) + { + _nativeFatEvidenceBindingGrid.BeginningEdit -= NativeFatEvidenceBinding_BeginningEdit; + _nativeFatEvidenceBindingGrid.CellEditEnding -= NativeFatEvidenceBinding_CellEditEnding; + _nativeFatEvidenceBindingGrid = null; + } + + if (_nativeFatEvidenceBindingArmHooked) + { + _nativeFatArmCoordinator.EvidenceChanged -= NativeFatEvidenceBinding_EvidenceChanged; + _nativeFatEvidenceBindingArmHooked = false; + } + + if (_nativeFatEvidenceBindingHydrationTimer != null) + { + _nativeFatEvidenceBindingHydrationTimer.Tick -= NativeFatEvidenceBindingHydrationTimer_Tick; + _nativeFatEvidenceBindingHydrationTimer.Stop(); + _nativeFatEvidenceBindingHydrationTimer = null; + } + + _nativeFatEvidenceBindingHydrationWasActive = false; + } +} From 0116c0623340d3f34b62aacfd3a6a693baa7b8eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:09:39 +0700 Subject: [PATCH 124/158] fix(fat): use DataContext-bound evidence columns --- MainWindow.NativeFatP4CColumnContract.cs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/MainWindow.NativeFatP4CColumnContract.cs b/MainWindow.NativeFatP4CColumnContract.cs index 5525a94a0..14037be29 100644 --- a/MainWindow.NativeFatP4CColumnContract.cs +++ b/MainWindow.NativeFatP4CColumnContract.cs @@ -40,22 +40,25 @@ private void ApplyNativeFatP4CColumnContract() AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 95); AddCanonicalTemplateColumn("Live Value", "ProcessValueBadgeTemplate", 125); + // P0 field hardening: evidence text is bound to the current DataContext. WPF row + // recycling therefore re-evaluates IEDName + IEC Telegram for the newly assigned + // canonical point instead of carrying imperative TextBlock.Text from a previous row. _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceColumn(this, "Value 1", NativeFatEvidenceField.Value1, 120)); + new NativeFatEvidenceBindingColumn("Value 1", NativeFatEvidenceField.Value1, 120, ReadNativeFatEvidence)); _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceColumn(this, "V1 Timestamp", NativeFatEvidenceField.Value1Timestamp, 185) + new NativeFatEvidenceBindingColumn("V1 Timestamp", NativeFatEvidenceField.Value1Timestamp, 185, ReadNativeFatEvidence) { IsReadOnly = true }); _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceColumn(this, "Value 2", NativeFatEvidenceField.Value2, 120)); + new NativeFatEvidenceBindingColumn("Value 2", NativeFatEvidenceField.Value2, 120, ReadNativeFatEvidence)); _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceColumn(this, "V2 Timestamp", NativeFatEvidenceField.Value2Timestamp, 185) + new NativeFatEvidenceBindingColumn("V2 Timestamp", NativeFatEvidenceField.Value2Timestamp, 185, ReadNativeFatEvidence) { IsReadOnly = true }); _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceColumn(this, "Result", NativeFatEvidenceField.Result, 110)); + new NativeFatEvidenceBindingColumn("Result", NativeFatEvidenceField.Result, 110, ReadNativeFatEvidence)); } private void NativeFatObservationStatus_EvidenceChanged(object? sender, NativeFatEvidenceChangedEventArgs e) @@ -89,8 +92,7 @@ void UpdateObservationStatus() } // Retained as an isolated formatter for report/tests and compatibility paths. The visible - // grid now routes timestamp fields through NativeFatEvidenceColumn so the existing evidence - // refresh loop updates Value, Timestamp and Result atomically after capture and hydration. + // grid routes all evidence fields through NativeFatEvidenceBindingColumn. private string ReadNativeFatTimestamp(Iec61850MonitorPoint point, NativeFatEvidenceField field) { if (string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) || @@ -108,9 +110,8 @@ private string ReadNativeFatTimestamp(Iec61850MonitorPoint point, NativeFatEvide } /// - /// Compatibility timestamp column retained for source/binary compatibility. The production - /// P4C grid uses read-only NativeFatEvidenceColumn timestamp fields so its established - /// RefreshNativeFatEvidenceCells loop refreshes all five evidence cells together. + /// Compatibility timestamp column retained for source/binary compatibility. Production + /// native FAT uses NativeFatEvidenceBindingColumn for timestamp fields as well. /// private sealed class NativeFatEvidenceTimestampColumn : DataGridColumn { From 0c50fbf920c68860f7e5b90a7458df8d41278343 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:09:59 +0700 Subject: [PATCH 125/158] fix(fat): install bound evidence runtime and flush on close --- MainWindow.ProductionFatTab.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index 2e6ac622f..156c1482e 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -80,6 +80,7 @@ private void ProductionFatInstallRetry_Tick(object? sender, EventArgs e) private FrameworkElement BuildProductionFatPermanentHost() { var host = BuildNativeFatCanonicalWorkspace(); + InstallNativeFatEvidenceBindingRuntime(); InstallNativeFatDiagnosticButtons(); return host; } @@ -119,6 +120,7 @@ private void SynchronizeProductionFatSelectedIed() BindNativeFatCanonicalRows(); BindNativeFatDiagnostics(SelectedDevice); + RefreshNativeFatEvidenceBindingRuntime(); } // Compatibility host contract: the global Engineering IED Explorer and shared Command Dock remain authoritative. @@ -168,6 +170,10 @@ private void ProductionFatWindow_Closed(object? sender, EventArgs e) private void ProductionFat_MainWindowClosed(object? sender, EventArgs e) { + // Persistence is fail-closed: write the latest sparse snapshot before any debounce + // cancellation or service disposal can discard the final FAT transition. + FlushNativeFatEvidenceBeforeShutdown(); + PropertyChanged -= ProductionFat_MainWindowPropertyChanged; MainTabs.SelectionChanged -= ProductionFat_MainTabsSelectionChanged; Closed -= ProductionFat_MainWindowClosed; @@ -176,6 +182,7 @@ private void ProductionFat_MainWindowClosed(object? sender, EventArgs e) _productionFatWindow = null; if (_nativeFatCanonicalGrid != null) _nativeFatCanonicalGrid.CellEditEnding -= NativeFatCanonicalGrid_CellEditEnding; + DisposeNativeFatEvidenceBindingRuntime(); DisposeNativeFatDiagnostics(); DisposeNativeFatArmCoordinator(); _nativeFatCanonicalGrid = null; From a0a4c4d29217eac28cbe5551a97f2a10bb71a8f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:10:07 +0700 Subject: [PATCH 126/158] refactor(fat): remove row-recycle text patch --- MainWindow.NativeFatFieldEvidenceFixes.cs | 163 ---------------------- 1 file changed, 163 deletions(-) delete mode 100644 MainWindow.NativeFatFieldEvidenceFixes.cs diff --git a/MainWindow.NativeFatFieldEvidenceFixes.cs b/MainWindow.NativeFatFieldEvidenceFixes.cs deleted file mode 100644 index ea7f944ed..000000000 --- a/MainWindow.NativeFatFieldEvidenceFixes.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.ComponentModel; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Threading; -using ArIED61850Tester.Models; - -namespace ArIED61850Tester; - -/// -/// Field hardening for native FAT evidence presentation/persistence. -/// Evidence identity remains IEDName + IEC Telegram; this layer only prevents recycled WPF -/// containers from showing stale evidence and guarantees the latest sparse evidence snapshot -/// is flushed before the native FAT persistence service is disposed on application close. -/// -public partial class MainWindow -{ - private bool _nativeFatFieldEvidenceHooksInstalled; - - [ModuleInitializer] - internal static void RegisterNativeFatFieldEvidenceHardening() - { - EventManager.RegisterClassHandler( - typeof(MainWindow), - FrameworkElement.LoadedEvent, - new RoutedEventHandler(NativeFatFieldEvidence_MainWindowLoaded), - handledEventsToo: true); - - EventManager.RegisterClassHandler( - typeof(DataGridRow), - FrameworkElement.LoadedEvent, - new RoutedEventHandler(NativeFatFieldEvidence_DataGridRowLoaded), - handledEventsToo: true); - } - - private static void NativeFatFieldEvidence_MainWindowLoaded(object sender, RoutedEventArgs e) - { - if (sender is not MainWindow window || window._nativeFatFieldEvidenceHooksInstalled) - return; - - window._nativeFatFieldEvidenceHooksInstalled = true; - window.Closing += window.NativeFatFieldEvidence_WindowClosing; - } - - private static void NativeFatFieldEvidence_DataGridRowLoaded(object sender, RoutedEventArgs e) - { - if (sender is not DataGridRow row || - Window.GetWindow(row) is not MainWindow window || - window._nativeFatCanonicalGrid == null || - !ReferenceEquals(ItemsControl.ItemsControlFromItemContainer(row), window._nativeFatCanonicalGrid)) - { - return; - } - - if (row.Tag is not NativeFatEvidenceRecycleHookMarker) - { - row.Tag = NativeFatEvidenceRecycleHookMarker.Instance; - row.DataContextChanged += NativeFatFieldEvidence_RowDataContextChanged; - } - - window.RefreshNativeFatEvidenceRowAfterRecycle(row); - } - - private static void NativeFatFieldEvidence_RowDataContextChanged( - object sender, - DependencyPropertyChangedEventArgs e) - { - if (sender is not DataGridRow row || - Window.GetWindow(row) is not MainWindow window || - window._nativeFatCanonicalGrid == null || - !ReferenceEquals(ItemsControl.ItemsControlFromItemContainer(row), window._nativeFatCanonicalGrid)) - { - return; - } - - // Fail closed immediately: a recycled visual must never show evidence from its - // previous DataContext while WPF finishes assigning the new canonical row. - window.ClearNativeFatEvidenceRowVisual(row); - window.RefreshNativeFatEvidenceRowAfterRecycle(row); - } - - private void ClearNativeFatEvidenceRowVisual(DataGridRow row) - { - if (_nativeFatCanonicalGrid == null) - return; - - foreach (var column in _nativeFatCanonicalGrid.Columns.OfType()) - { - if (column.GetCellContent(row) is TextBlock textBlock) - textBlock.Text = string.Empty; - } - } - - private void RefreshNativeFatEvidenceRowAfterRecycle(DataGridRow row) - { - Dispatcher.BeginInvoke( - () => - { - if (_nativeFatCanonicalGrid == null || - !ReferenceEquals(ItemsControl.ItemsControlFromItemContainer(row), _nativeFatCanonicalGrid) || - row.Item is not Iec61850MonitorPoint point) - { - return; - } - - // Re-read by the current canonical point. ReadNativeFatEvidence ultimately - // resolves only IEDName + IEC Telegram; row index and SignalName never enter. - RefreshNativeFatEvidenceCells(point); - }, - DispatcherPriority.DataBind); - } - - private void NativeFatFieldEvidence_WindowClosing(object? sender, CancelEventArgs e) - { - CommitNativeFatEvidenceEdits(); - FlushNativeFatEvidenceBeforeShutdown(); - } - - private void FlushNativeFatEvidenceBeforeShutdown() - { - foreach (var device in Devices.ToArray()) - { - if (!_nativeFatSessionByIed.TryGetValue(device.DeviceId, out var cache)) - continue; - - var hasEvidence = false; - lock (cache.EvidenceByRow) - hasEvidence = cache.EvidenceByRow.Count > 0; - if (!hasEvidence) - continue; - - try - { - // SaveAsync snapshots the canonical rows synchronously before yielding and - // writes an atomic file whose path is derived from IEDName, not DeviceId. - // Closing waits for this small sparse snapshot so the subsequent Closed - // cleanup cannot cancel the only pending persistence operation. - _nativeFatEvidenceHydrationService - .SaveAsync(device, cache, CancellationToken.None) - .GetAwaiter() - .GetResult(); - - Trace.WriteLine( - $"[FAT field] evidence flush completed before shutdown; ied={device.Name}; deviceId={device.DeviceId}; rows={cache.EvidenceByRow.Count}."); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) - { - Trace.WriteLine( - $"[FAT field] evidence flush failed before shutdown for {device.Name}: {ex.Message}"); - } - } - } - - private sealed class NativeFatEvidenceRecycleHookMarker - { - public static NativeFatEvidenceRecycleHookMarker Instance { get; } = new(); - - private NativeFatEvidenceRecycleHookMarker() - { - } - } -} From 0e125997d74d4fff109ffccf964eb83f4fc0ffa7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:10:37 +0700 Subject: [PATCH 127/158] fix(report): fit logos adaptively in header slot --- Services/IoTesting/NativeFatReportImage.cs | 137 ++++++++++++++++++--- 1 file changed, 118 insertions(+), 19 deletions(-) diff --git a/Services/IoTesting/NativeFatReportImage.cs b/Services/IoTesting/NativeFatReportImage.cs index b7bfa6c55..eb46b2808 100644 --- a/Services/IoTesting/NativeFatReportImage.cs +++ b/Services/IoTesting/NativeFatReportImage.cs @@ -23,6 +23,12 @@ internal sealed record NativeFatReportLogo( byte[] RgbPixels, string SourceName); +internal readonly record struct NativeFatLogoPlacement( + double X, + double TopY, + double Width, + double Height); + /// /// Native FAT report logo authority. The default mark is the real packaged ARSAS app icon. /// Custom logos are decoded once and stored in the immutable report command stream, so Preview @@ -31,10 +37,20 @@ internal sealed record NativeFatReportLogo( internal static class NativeFatReportLogoService { private const int MaxPixelDimension = 512; - private const double NativeLogoX = 710d; + private const byte VisibleAlphaThreshold = 8; + + // Legacy synthetic ARSAS mark coordinates retained only so the decorator can remove it. + private const double LegacySyntheticLogoX = 710d; private const double LegacySyntheticLogoTop = 582d; - private const double NativeLogoTop = 576d; - private const double NativeLogoSize = 22d; + private const double LegacySyntheticLogoSize = 22d; + + // Professional adaptive header slot. The top header has substantially more room than the + // legacy 22 x 22 icon box. Wide corporate wordmarks can now use the available width while + // square/circular marks use the full height without distortion or cropping. + private const double HeaderLogoSlotLeft = 656d; + private const double HeaderLogoSlotTopY = 578d; + private const double HeaderLogoSlotWidth = 156d; + private const double HeaderLogoSlotHeight = 42d; private static readonly string[] DefaultLogoUris = [ @@ -84,9 +100,6 @@ public static IoFatReportLayoutPlan Apply(IoFatReportLayoutPlan layout, NativeFa var commands = new List(page.Commands.Count + 1); foreach (var command in page.Commands) { - // The base layout still carries the legacy synthetic icon/wordmark so - // older non-image report paths remain structurally compatible. Native - // Preview/PDF replaces the complete legacy mark with the real app icon. if (IsLegacyBrandingCommand(command)) continue; commands.Add(command); @@ -94,14 +107,18 @@ public static IoFatReportLayoutPlan Apply(IoFatReportLayoutPlan layout, NativeFa if (logo != null) { - commands.Add(new IoFatReportImageCommand( - NativeLogoX, - NativeLogoTop, - NativeLogoSize, - NativeLogoSize, - logo.PixelWidth, - logo.PixelHeight, - logo.RgbPixels)); + var placement = CalculatePlacement(logo); + if (placement.Width > 0d && placement.Height > 0d) + { + commands.Add(new IoFatReportImageCommand( + placement.X, + placement.TopY, + placement.Width, + placement.Height, + logo.PixelWidth, + logo.PixelHeight, + logo.RgbPixels)); + } } return new IoFatReportPagePlan(page.PageNumber, page.Width, page.Height, commands.ToArray()); @@ -111,23 +128,47 @@ public static IoFatReportLayoutPlan Apply(IoFatReportLayoutPlan layout, NativeFa return new IoFatReportLayoutPlan(layout.ProjectId, layout.CreatedAt, layout.Draft, pages); } + /// + /// Uniform-fit placement inside one fixed header field. This deliberately preserves aspect + /// ratio: wide logos consume width, square/circular logos consume height, and neither is + /// stretched or cropped. The result is right-aligned and vertically centered in the slot. + /// + internal static NativeFatLogoPlacement CalculatePlacement(NativeFatReportLogo logo) + { + ArgumentNullException.ThrowIfNull(logo); + if (logo.PixelWidth <= 0 || logo.PixelHeight <= 0) + return default; + + var scale = Math.Min( + HeaderLogoSlotWidth / logo.PixelWidth, + HeaderLogoSlotHeight / logo.PixelHeight); + if (!double.IsFinite(scale) || scale <= 0d) + return default; + + var width = logo.PixelWidth * scale; + var height = logo.PixelHeight * scale; + var x = HeaderLogoSlotLeft + HeaderLogoSlotWidth - width; + var topY = HeaderLogoSlotTopY - ((HeaderLogoSlotHeight - height) / 2d); + return new NativeFatLogoPlacement(x, topY, width, height); + } + private static bool IsLegacyBrandingCommand(IoFatReportCommand command) { if (command is IoFatReportRectCommand rect) { - return Near(rect.X, NativeLogoX) && + return Near(rect.X, LegacySyntheticLogoX) && Near(rect.TopY, LegacySyntheticLogoTop) && - Near(rect.Width, NativeLogoSize) && - Near(rect.Height, NativeLogoSize); + Near(rect.Width, LegacySyntheticLogoSize) && + Near(rect.Height, LegacySyntheticLogoSize); } if (command is IoFatReportTextCommand text) { var syntheticA = string.Equals(text.Text, "A", StringComparison.Ordinal) && - Near(text.X, NativeLogoX + 5.2d) && + Near(text.X, LegacySyntheticLogoX + 5.2d) && Near(text.BaselineY, LegacySyntheticLogoTop - 15.2d); var legacyWordmark = string.Equals(text.Text, "ARSAS", StringComparison.Ordinal) && - Near(text.X, NativeLogoX + 29d) && + Near(text.X, LegacySyntheticLogoX + 29d) && Near(text.BaselineY, LegacySyntheticLogoTop - 15.4d); return syntheticA || legacyWordmark; } @@ -143,6 +184,11 @@ private static NativeFatReportLogo Decode(Stream stream, string sourceName) BitmapCacheOption.OnLoad); BitmapSource source = decoder.Frames[0]; + // Remove transparent canvas padding before sizing. Corporate PNGs often contain a + // large transparent artboard; fitting the full canvas would make the visible logo look + // artificially tiny even when the destination slot itself is large. + source = TrimTransparentPadding(source); + var largest = Math.Max(source.PixelWidth, source.PixelHeight); if (largest > MaxPixelDimension) { @@ -176,6 +222,59 @@ private static NativeFatReportLogo Decode(Stream stream, string sourceName) return new NativeFatReportLogo(width, height, rgb, sourceName); } + private static BitmapSource TrimTransparentPadding(BitmapSource source) + { + var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0d); + var width = converted.PixelWidth; + var height = converted.PixelHeight; + if (width <= 0 || height <= 0) + return converted; + + var stride = checked(width * 4); + var pixels = new byte[checked(stride * height)]; + converted.CopyPixels(pixels, stride, 0); + var bounds = FindVisibleBounds(pixels, width, height); + if (bounds.IsEmpty || + (bounds.X == 0 && bounds.Y == 0 && bounds.Width == width && bounds.Height == height)) + { + return converted; + } + + return new CroppedBitmap(converted, bounds); + } + + internal static Int32Rect FindVisibleBounds(byte[] bgra, int width, int height) + { + ArgumentNullException.ThrowIfNull(bgra); + if (width <= 0 || height <= 0 || bgra.Length < checked(width * height * 4)) + return Int32Rect.Empty; + + var minX = width; + var minY = height; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < height; y++) + { + var rowOffset = y * width * 4; + for (var x = 0; x < width; x++) + { + var alpha = bgra[rowOffset + (x * 4) + 3]; + if (alpha <= VisibleAlphaThreshold) + continue; + + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + + return maxX < minX || maxY < minY + ? Int32Rect.Empty + : new Int32Rect(minX, minY, maxX - minX + 1, maxY - minY + 1); + } + private static byte CompositeOnWhite(byte channel, byte alpha) => (byte)((channel * alpha + 255 * (255 - alpha) + 127) / 255); From 201345ae78ffe836b5335a5ab5094c8cedc14911 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:12:45 +0700 Subject: [PATCH 128/158] test(fat): exercise real WPF row recycling and adaptive logos --- .../NativeFatFieldEvidenceRegressionTests.cs | 262 ++++++++++++++++-- 1 file changed, 234 insertions(+), 28 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs b/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs index 48c8b1348..cd08f82d5 100644 --- a/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs @@ -1,3 +1,9 @@ +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Threading; using ArIED61850Tester.Models; using ArIED61850Tester.Services.IoTesting; @@ -60,43 +66,243 @@ public async Task RestartWithNewRuntimeDeviceId_RestoresOnlyExactIedNameAndTeleg } [Fact] - public void RecycledGridRows_ClearStaleEvidenceAndRefreshFromCurrentCanonicalPoint() + public void RecyclingDataGrid_BoundEvidenceNeverMovesFromCswiToAnotherTelegram() { - var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatFieldEvidenceFixes.cs")); - - Assert.Contains("row.DataContextChanged += NativeFatFieldEvidence_RowDataContextChanged", source, StringComparison.Ordinal); - Assert.Contains("ClearNativeFatEvidenceRowVisual(row)", source, StringComparison.Ordinal); - Assert.Contains("row.Item is not Iec61850MonitorPoint point", source, StringComparison.Ordinal); - Assert.Contains("RefreshNativeFatEvidenceCells(point)", source, StringComparison.Ordinal); - Assert.Contains("IEDName + IEC Telegram", source, StringComparison.Ordinal); - Assert.DoesNotContain("SelectedIndex", source, StringComparison.Ordinal); - Assert.DoesNotContain("point.SignalName", source, StringComparison.Ordinal); + RunSta(() => + { + var device = Device("runtime-ui"); + for (var index = 0; index < 72; index++) + { + device.Points.Add(Point( + device, + $"Signal {index:00}", + $"ADD/GGIO{index / 8 + 1}.Ind{index:00}.stVal", + "false")); + } + + var cswi = Point(device, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Closed [10]"); + device.Points.Insert(8, cswi); + var thd = Point(device, "ThdPPV PhsBC", "VI3p1_THDHarmonics/V_MHAI1.ThdPPV.phsBC.cVal.mag.f", "0"); + device.Points.Insert(54, thd); + + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + cswi, + NativeFatEvidenceField.Value1, + "Closed [10]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Parse("2026-09-13T11:01:51.329+07:00")); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + cswi, + NativeFatEvidenceField.Value2, + "Open [01]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Parse("2026-09-13T11:32:48.064+07:00")); + + string Read(Iec61850MonitorPoint point, NativeFatEvidenceField field) + => NativeFatCanonicalEvidenceOverlay.Read(cache, point, field); + + var value1 = new NativeFatEvidenceBindingColumn("Value 1", NativeFatEvidenceField.Value1, 120, Read); + var value2 = new NativeFatEvidenceBindingColumn("Value 2", NativeFatEvidenceField.Value2, 120, Read); + var result = new NativeFatEvidenceBindingColumn("Result", NativeFatEvidenceField.Result, 100, Read); + var grid = new DataGrid + { + Width = 620, + Height = 180, + AutoGenerateColumns = false, + CanUserAddRows = false, + EnableRowVirtualization = true, + EnableColumnVirtualization = true, + ItemsSource = device.Points + }; + VirtualizingPanel.SetIsVirtualizing(grid, true); + VirtualizingPanel.SetVirtualizationMode(grid, VirtualizationMode.Recycling); + ScrollViewer.SetCanContentScroll(grid, true); + grid.Columns.Add(new DataGridTextColumn + { + Header = "Signal", + Binding = new Binding(nameof(Iec61850MonitorPoint.SignalName)), + Width = 180 + }); + grid.Columns.Add(value1); + grid.Columns.Add(value2); + grid.Columns.Add(result); + + var window = new Window + { + Width = 660, + Height = 220, + ShowActivated = false, + ShowInTaskbar = false, + WindowStyle = WindowStyle.ToolWindow, + Content = grid + }; + + window.Show(); + try + { + Pump(grid); + AssertRealizedRowsMatch(grid, value1, value2, result, cache); + + foreach (var target in new[] + { + device.Points[0], + thd, + device.Points[^1], + cswi, + thd, + device.Points[20], + cswi + }) + { + grid.ScrollIntoView(target); + grid.UpdateLayout(); + Pump(grid); + AssertRealizedRowsMatch(grid, value1, value2, result, cache); + } + + grid.ScrollIntoView(thd); + grid.UpdateLayout(); + Pump(grid); + Assert.Equal(string.Empty, CellText(value1, thd)); + Assert.Equal(string.Empty, CellText(value2, thd)); + Assert.Equal(string.Empty, CellText(result, thd)); + + grid.ScrollIntoView(cswi); + grid.UpdateLayout(); + Pump(grid); + Assert.Equal("Closed [10]", CellText(value1, cswi)); + Assert.Equal("Open [01]", CellText(value2, cswi)); + Assert.Equal("OK", CellText(result, cswi)); + } + finally + { + window.Close(); + Pump(grid); + } + }); + } + + [Fact] + public void EvidenceBindingRuntime_UsesBindingTargetsAndHasNoRowRecycleTextPatch() + { + var binding = File.ReadAllText(FindRepoFile("MainWindow.NativeFatEvidenceBindingRuntime.cs")); + var tab = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + Assert.Contains("Path = new PropertyPath(\".\")", binding, StringComparison.Ordinal); + Assert.Contains("GetBindingExpression(TextBlock.TextProperty)?.UpdateTarget()", binding, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceBindingColumn", binding, StringComparison.Ordinal); + Assert.DoesNotContain("row.DataContextChanged", binding, StringComparison.Ordinal); + Assert.DoesNotContain("textBlock.Text =", binding, StringComparison.Ordinal); + Assert.Contains("FlushNativeFatEvidenceBeforeShutdown();", tab, StringComparison.Ordinal); + Assert.False(File.Exists(FindRepoFile("MainWindow.NativeFatFieldEvidenceFixes.cs"))); } [Fact] - public void ApplicationClosing_FlushesEvidenceBeforeClosedCleanupCanCancelDebounce() + public void ReportLogoPlacement_WideAndSquareMarksUseHeaderSpaceWithoutDistortion() { - var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatFieldEvidenceFixes.cs")); - var production = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); - - Assert.Contains("window.Closing += window.NativeFatFieldEvidence_WindowClosing", source, StringComparison.Ordinal); - Assert.Contains("FlushNativeFatEvidenceBeforeShutdown()", source, StringComparison.Ordinal); - Assert.Contains("SaveAsync(device, cache, CancellationToken.None)", source, StringComparison.Ordinal); - Assert.Contains("Closed += ProductionFat_MainWindowClosed", production, StringComparison.Ordinal); - Assert.Contains("DisposeNativeFatArmCoordinator()", production, StringComparison.Ordinal); + var wide = new NativeFatReportLogo(400, 100, Array.Empty(), "wide"); + var square = new NativeFatReportLogo(256, 256, Array.Empty(), "square"); + + var widePlacement = NativeFatReportLogoService.CalculatePlacement(wide); + var squarePlacement = NativeFatReportLogoService.CalculatePlacement(square); + + Assert.InRange(widePlacement.Width, 150d, 156d); + Assert.InRange(widePlacement.Height, 37d, 40d); + Assert.Equal(4d, widePlacement.Width / widePlacement.Height, 6); + + Assert.Equal(42d, squarePlacement.Width, 6); + Assert.Equal(42d, squarePlacement.Height, 6); + Assert.True(squarePlacement.X > widePlacement.X); + Assert.InRange(squarePlacement.TopY, 577.9d, 578.1d); } [Fact] - public void ReportBranding_UsesIconOnly_LowersLogo_AndSignOffHasNoScopeCard() + public void ReportLogo_TransparentCanvasIsTrimmedBeforeAdaptiveFit() + { + const int width = 12; + const int height = 10; + var bgra = new byte[width * height * 4]; + for (var y = 3; y <= 6; y++) + { + for (var x = 2; x <= 9; x++) + bgra[((y * width) + x) * 4 + 3] = 255; + } + + var bounds = NativeFatReportLogoService.FindVisibleBounds(bgra, width, height); + + Assert.Equal(2, bounds.X); + Assert.Equal(3, bounds.Y); + Assert.Equal(8, bounds.Width); + Assert.Equal(4, bounds.Height); + } + + private static void AssertRealizedRowsMatch( + DataGrid grid, + NativeFatEvidenceBindingColumn value1, + NativeFatEvidenceBindingColumn value2, + NativeFatEvidenceBindingColumn result, + NativeFatIedSessionCacheState cache) { - var image = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportImage.cs")); - var finalization = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportFinalization.cs")); - - Assert.Contains("private const double NativeLogoTop = 576d", image, StringComparison.Ordinal); - Assert.Contains("legacyWordmark", image, StringComparison.Ordinal); - Assert.Contains("string.Equals(text.Text, \"ARSAS\"", image, StringComparison.Ordinal); - Assert.DoesNotContain("IED / REPORT SCOPE", finalization, StringComparison.Ordinal); - Assert.DoesNotContain("FOR FAT RECORD", finalization, StringComparison.Ordinal); + foreach (var item in grid.Items.OfType()) + { + if (grid.ItemContainerGenerator.ContainerFromItem(item) is not DataGridRow) + continue; + + Assert.Equal( + NativeFatCanonicalEvidenceOverlay.Read(cache, item, NativeFatEvidenceField.Value1), + CellText(value1, item)); + Assert.Equal( + NativeFatCanonicalEvidenceOverlay.Read(cache, item, NativeFatEvidenceField.Value2), + CellText(value2, item)); + Assert.Equal( + NativeFatCanonicalEvidenceOverlay.Read(cache, item, NativeFatEvidenceField.Result), + CellText(result, item)); + } + } + + private static string CellText(NativeFatEvidenceBindingColumn column, Iec61850MonitorPoint point) + => column.GetCellContent(point) switch + { + TextBlock block => block.Text, + TextBox editor => editor.Text, + _ => string.Empty + }; + + private static void Pump(DispatcherObject owner) + => owner.Dispatcher.Invoke(DispatcherPriority.ApplicationIdle, new Action(() => { })); + + private static void RunSta(Action action) + { + Exception? failure = null; + using var done = new ManualResetEventSlim(false); + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + failure = ex; + } + finally + { + done.Set(); + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + thread.Start(); + + if (!done.Wait(TimeSpan.FromSeconds(25))) + throw new TimeoutException("STA WPF recycling regression test exceeded 25 seconds."); + + thread.Join(); + if (failure != null) + ExceptionDispatchInfo.Capture(failure).Throw(); } private static Iec61850MonitorDevice Device(string deviceId) From 06d7b5e98eb0ecb637415b6efe3064d1b37890d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:12:54 +0700 Subject: [PATCH 129/158] test(fat): enable WPF recycling regression harness --- tests/ARSAS.Tests/ARSAS.Tests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ARSAS.Tests/ARSAS.Tests.csproj b/tests/ARSAS.Tests/ARSAS.Tests.csproj index bc7961d33..c70414d91 100644 --- a/tests/ARSAS.Tests/ARSAS.Tests.csproj +++ b/tests/ARSAS.Tests/ARSAS.Tests.csproj @@ -2,6 +2,7 @@ net8.0-windows true + true false true enable From 86b7a18b00ae34868d57d5dc2ae5d4d9e8370ea2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:13:17 +0700 Subject: [PATCH 130/158] test(fat): expect DataContext-bound evidence columns --- .../ProductionFatP1CanonicalGridRegressionTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs index f425d89e5..811736e28 100644 --- a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs @@ -57,8 +57,8 @@ public void P1C_NativeFatUsesEngineeringGridStyleTemplateAndVirtualizationContra Assert.Contains("FindResource(\"ModernDataGrid\") as Style", gridSource, StringComparison.Ordinal); Assert.Contains("ApplyNativeFatP4CColumnContract();", gridSource, StringComparison.Ordinal); Assert.Contains("AddCanonicalTemplateColumn(\"Live Value\", \"ProcessValueBadgeTemplate\", 125);", columnContract, StringComparison.Ordinal); - Assert.Contains("new NativeFatEvidenceColumn(this, \"V1 Timestamp\", NativeFatEvidenceField.Value1Timestamp, 185)", columnContract, StringComparison.Ordinal); - Assert.Contains("new NativeFatEvidenceColumn(this, \"V2 Timestamp\", NativeFatEvidenceField.Value2Timestamp, 185)", columnContract, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"V1 Timestamp\", NativeFatEvidenceField.Value1Timestamp, 185, ReadNativeFatEvidence)", columnContract, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"V2 Timestamp\", NativeFatEvidenceField.Value2Timestamp, 185, ReadNativeFatEvidence)", columnContract, StringComparison.Ordinal); Assert.Contains("VirtualizingPanel.SetVirtualizationMode(_nativeFatCanonicalGrid, VirtualizationMode.Recycling);", gridSource, StringComparison.Ordinal); Assert.Contains("RowStyle = BuildEngineeringLiveRowStyle()", gridSource, StringComparison.Ordinal); Assert.Contains("CellStyle = BuildEngineeringLiveCellStyle()", gridSource, StringComparison.Ordinal); @@ -87,4 +87,4 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException( $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } -} \ No newline at end of file +} From fd246bc2f423eee0bae27c98cb96255dbb452b40 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:13:44 +0700 Subject: [PATCH 131/158] test(fat): validate bound evidence refresh authority --- .../NativeFatP4CCanonicalColumnContractTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs index 46772fe56..8f3bbcfe9 100644 --- a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs @@ -47,13 +47,14 @@ public void P4C_FatExposesExactNineColumnExplorerEvidenceContract() public void P4C_TimestampColumnsShareTheEvidenceRefreshAuthority() { var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); - var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); var overlay = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); + var binding = File.ReadAllText(FindRepoFile("MainWindow.NativeFatEvidenceBindingRuntime.cs")); Assert.Contains("NativeFatEvidenceField.Value1Timestamp", source, StringComparison.Ordinal); Assert.Contains("NativeFatEvidenceField.Value2Timestamp", source, StringComparison.Ordinal); Assert.Contains("IsReadOnly = true", source, StringComparison.Ordinal); - Assert.Contains("Columns.OfType()", gridSource, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceBindingColumn", source, StringComparison.Ordinal); + Assert.Contains("GetBindingExpression(TextBlock.TextProperty)?.UpdateTarget()", binding, StringComparison.Ordinal); Assert.Contains("NativeFatEvidenceField.Value1Timestamp => TimestampValue(slot.Value1Evidence)", overlay, StringComparison.Ordinal); Assert.Contains("NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence)", overlay, StringComparison.Ordinal); } @@ -93,12 +94,15 @@ public void P4C_CanonicalGridBuilderHasNoLegacyColumnInstallationPath() public void P4C_ColumnBindingsUseCanonicalExplorerRowProperties() { var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var binding = File.ReadAllText(FindRepoFile("MainWindow.NativeFatEvidenceBindingRuntime.cs")); Assert.Contains("nameof(Iec61850MonitorPoint.SignalName)", source, StringComparison.Ordinal); Assert.Contains("nameof(Iec61850MonitorPoint.IecTelegram)", source, StringComparison.Ordinal); Assert.Contains("nameof(Iec61850MonitorPoint.Quality)", source, StringComparison.Ordinal); Assert.Contains("\"ProcessValueBadgeTemplate\"", source, StringComparison.Ordinal); - Assert.Contains("NativeFatEvidenceColumn", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceBindingColumn", source, StringComparison.Ordinal); + Assert.Contains("Path = new PropertyPath(\".\")", binding, StringComparison.Ordinal); + Assert.Contains("value is Iec61850MonitorPoint point", binding, StringComparison.Ordinal); } private static string FindRepoFile(string relativePath) From be32d1e1c3d5a64ad249b773ed742f47dccf59f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 12:26:33 +0700 Subject: [PATCH 132/158] test(fat): import root ARSAS namespace for WPF harness --- tests/ARSAS.Tests/GlobalUsings.Arsas.cs | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/ARSAS.Tests/GlobalUsings.Arsas.cs diff --git a/tests/ARSAS.Tests/GlobalUsings.Arsas.cs b/tests/ARSAS.Tests/GlobalUsings.Arsas.cs new file mode 100644 index 000000000..f6d242668 --- /dev/null +++ b/tests/ARSAS.Tests/GlobalUsings.Arsas.cs @@ -0,0 +1 @@ +global using ArIED61850Tester; From 6e5366e73e3597eeb3c7a7f6ed937b188d63010c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 13:45:06 +0700 Subject: [PATCH 133/158] fix(ci): expose internals to regression tests --- Properties/InternalsVisibleTo.cs | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Properties/InternalsVisibleTo.cs diff --git a/Properties/InternalsVisibleTo.cs b/Properties/InternalsVisibleTo.cs new file mode 100644 index 000000000..63d6d0efe --- /dev/null +++ b/Properties/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ARSAS.Tests")] From 48e1710bdf815eaf6053568dd7fa0e64d6880782 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 13:51:49 +0700 Subject: [PATCH 134/158] fix(ci): restore test implicit IO namespace --- tests/ARSAS.Tests/GlobalUsings.Arsas.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ARSAS.Tests/GlobalUsings.Arsas.cs b/tests/ARSAS.Tests/GlobalUsings.Arsas.cs index f6d242668..0d49110c5 100644 --- a/tests/ARSAS.Tests/GlobalUsings.Arsas.cs +++ b/tests/ARSAS.Tests/GlobalUsings.Arsas.cs @@ -1 +1,3 @@ +global using System.IO; global using ArIED61850Tester; +global using Iec61850MonitorRuntime = ArIED61850Tester.Services.Iec61850MonitorRuntime; From b678b143a7bfc12a8f1a25e75dd473e6b97f4bee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:09:55 +0700 Subject: [PATCH 135/158] fix(fat): freeze evidence before per-IED persistence --- ...NativeFatEvidencePersistenceCoordinator.cs | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs diff --git a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs new file mode 100644 index 000000000..040e0d691 --- /dev/null +++ b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs @@ -0,0 +1,175 @@ +using System.Diagnostics; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Immutable FAT evidence write input captured while the Engineering IED and its canonical +/// rows are still alive. Persistence must never enumerate a live WPF-bound collection after +/// an IED has started closing/removing from the Engineering workspace. +/// +internal sealed class NativeFatEvidenceDurabilitySnapshot +{ + private NativeFatEvidenceDurabilitySnapshot( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache) + { + Device = device; + Cache = cache; + StableIedName = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(device.Name); + } + + internal Iec61850MonitorDevice Device { get; } + internal NativeFatIedSessionCacheState Cache { get; } + internal string StableIedName { get; } + + internal static NativeFatEvidenceDurabilitySnapshot Capture( + Iec61850MonitorDevice source, + NativeFatIedSessionCacheState sourceCache) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(sourceCache); + + var detachedDevice = new Iec61850MonitorDevice + { + DeviceId = source.DeviceId, + Name = source.Name, + IpAddress = source.IpAddress, + Port = source.Port + }; + + // SaveAsync needs only canonical row identity. Copy that identity now so a later + // RemoveDevicePoints/Points.Clear cannot turn a valid evidence snapshot into an + // empty snapshot while a debounced/background write is still pending. + foreach (var point in source.Points) + { + detachedDevice.Points.Add(new Iec61850MonitorPoint + { + DeviceId = point.DeviceId, + DeviceName = point.DeviceName, + IpAddress = point.IpAddress, + SignalName = point.SignalName, + IecReference = point.IecReference + }); + } + + var detachedCache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing( + detachedCache, + NativeFatCanonicalEvidenceOverlay.Snapshot(sourceCache)); + + return new NativeFatEvidenceDurabilitySnapshot(detachedDevice, detachedCache); + } +} + +/// +/// Lightweight per-IED persistence worker. There is no permanent thread: a worker exists +/// only while an IED has dirty evidence. Writes for the same stable IEDName are serialized +/// and the newest generation is always persisted last. +/// +internal sealed class NativeFatEvidencePersistenceCoordinator +{ + private readonly NativeFatEvidenceHydrationService _service; + private readonly object _gate = new(); + private readonly Dictionary _stateByIed = + new(StringComparer.OrdinalIgnoreCase); + + internal NativeFatEvidencePersistenceCoordinator(NativeFatEvidenceHydrationService service) + => _service = service ?? throw new ArgumentNullException(nameof(service)); + + internal void Queue(NativeFatEvidenceDurabilitySnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (string.IsNullOrWhiteSpace(snapshot.StableIedName)) + return; + + lock (_gate) + { + if (!_stateByIed.TryGetValue(snapshot.StableIedName, out var state)) + { + state = new IedWriteState(); + _stateByIed[snapshot.StableIedName] = state; + } + + state.Latest = snapshot; + state.Generation++; + if (state.Worker == null || state.Worker.IsCompleted) + state.Worker = Task.Run(() => RunWorkerAsync(snapshot.StableIedName, state)); + } + } + + internal async Task DrainAsync(string iedName) + { + var key = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + while (true) + { + Task? worker; + lock (_gate) + { + if (!_stateByIed.TryGetValue(key, out var state) || state.Worker == null) + return; + worker = state.Worker; + } + + await worker.ConfigureAwait(false); + + lock (_gate) + { + if (!_stateByIed.TryGetValue(key, out var state) || + state.Worker == null || + ReferenceEquals(state.Worker, worker)) + { + return; + } + } + } + } + + private async Task RunWorkerAsync(string stableIedName, IedWriteState state) + { + while (true) + { + NativeFatEvidenceDurabilitySnapshot snapshot; + long generation; + lock (_gate) + { + snapshot = state.Latest + ?? throw new InvalidOperationException("FAT evidence worker has no pending snapshot."); + generation = state.Generation; + } + + try + { + await _service + .SaveAsync(snapshot.Device, snapshot.Cache, CancellationToken.None) + .ConfigureAwait(false); + Trace.WriteLine( + $"[FAT durability] persisted frozen evidence; ied={snapshot.Device.Name}; generation={generation}; rows={NativeFatCanonicalEvidenceOverlay.Snapshot(snapshot.Cache).Count}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ObjectDisposedException) + { + Trace.WriteLine( + $"[FAT durability] evidence persistence failed for {snapshot.Device.Name}: {ex.Message}"); + } + + lock (_gate) + { + if (generation == state.Generation) + { + state.Worker = null; + return; + } + + // Evidence changed while this write was in flight. Loop immediately and + // persist only the newest frozen generation after the older write completes. + } + } + } + + private sealed class IedWriteState + { + internal NativeFatEvidenceDurabilitySnapshot? Latest { get; set; } + internal long Generation { get; set; } + internal Task? Worker { get; set; } + } +} From 601985d8c9f054f372f2eb25a56777211952c442 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:10:20 +0700 Subject: [PATCH 136/158] fix(fat): persist capture before IED teardown --- MainWindow.NativeFatEvidenceDurability.cs | 137 ++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 MainWindow.NativeFatEvidenceDurability.cs diff --git a/MainWindow.NativeFatEvidenceDurability.cs b/MainWindow.NativeFatEvidenceDurability.cs new file mode 100644 index 000000000..90ddcdd69 --- /dev/null +++ b/MainWindow.NativeFatEvidenceDurability.cs @@ -0,0 +1,137 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Release durability guard for native FAT evidence. Capture persistence is frozen at the +/// evidence event/edit boundary, before an Engineering IED can clear/remove its live rows. +/// This intentionally leaves the canonical WPF binding/recycling path unchanged. +/// +public partial class MainWindow +{ + private bool _nativeFatEvidenceDurabilityInstalled; + private DataGrid? _nativeFatEvidenceDurabilityGrid; + private NativeFatEvidencePersistenceCoordinator? _nativeFatEvidencePersistenceCoordinator; + + [ModuleInitializer] + internal static void RegisterNativeFatEvidenceDurability() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatEvidenceDurability_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatEvidenceDurability_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatEvidenceDurabilityInstalled) + return; + + // Production FAT builds its canonical grid during Loaded. Hook after that install so + // the durability handler runs in addition to, not instead of, the proven binding path. + window.Dispatcher.BeginInvoke( + new Action(window.InstallNativeFatEvidenceDurability), + DispatcherPriority.ApplicationIdle); + } + + private void InstallNativeFatEvidenceDurability() + { + if (_nativeFatEvidenceDurabilityInstalled || !IsLoaded) + return; + + _nativeFatEvidenceDurabilityInstalled = true; + _nativeFatEvidencePersistenceCoordinator ??= + new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceHydrationService); + + _nativeFatArmCoordinator.EvidenceChanged += NativeFatEvidenceDurability_EvidenceChanged; + MainTabs.SelectionChanged += NativeFatEvidenceDurability_MainTabsSelectionChanged; + EnsureNativeFatEvidenceDurabilityGridHook(); + } + + private void NativeFatEvidenceDurability_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ReferenceEquals(e.Source, MainTabs)) + EnsureNativeFatEvidenceDurabilityGridHook(); + } + + private void EnsureNativeFatEvidenceDurabilityGridHook() + { + var grid = _nativeFatCanonicalGrid; + if (ReferenceEquals(_nativeFatEvidenceDurabilityGrid, grid)) + return; + + if (_nativeFatEvidenceDurabilityGrid != null) + _nativeFatEvidenceDurabilityGrid.CellEditEnding -= NativeFatEvidenceDurability_CellEditEnding; + + _nativeFatEvidenceDurabilityGrid = grid; + if (_nativeFatEvidenceDurabilityGrid != null) + _nativeFatEvidenceDurabilityGrid.CellEditEnding += NativeFatEvidenceDurability_CellEditEnding; + } + + private void NativeFatEvidenceDurability_EvidenceChanged( + object? sender, + NativeFatEvidenceChangedEventArgs e) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke( + () => NativeFatEvidenceDurability_EvidenceChanged(sender, e), + DispatcherPriority.DataBind); + return; + } + + QueueFrozenNativeFatEvidence(e.DeviceId, "capture"); + } + + private void NativeFatEvidenceDurability_CellEditEnding( + object? sender, + DataGridCellEditEndingEventArgs e) + { + if (e.EditAction != DataGridEditAction.Commit || + e.Row.Item is not Iec61850MonitorPoint point || + e.Column is not NativeFatEvidenceBindingColumn evidenceColumn || + e.EditingElement is not TextBox editor || + string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + { + return; + } + + // Make the durability guard order-independent from the existing binding handler. + // Write is idempotent for an unchanged V1/V2 value, so either handler may run first. + var cache = GetNativeFatSession(_nativeFatBoundIedKey); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, evidenceColumn.Field, editor.Text); + cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + QueueFrozenNativeFatEvidence(_nativeFatBoundIedKey, "operator edit"); + } + + private void QueueFrozenNativeFatEvidence(string deviceId, string reason) + { + var device = Devices.FirstOrDefault(candidate => + candidate.DeviceId.Equals(deviceId, StringComparison.OrdinalIgnoreCase)); + if (device == null || !_nativeFatSessionByIed.TryGetValue(deviceId, out var cache)) + return; + + // Cancel the old 350 ms live-device debounce. Its SaveAsync would enumerate + // device.Points later and can therefore observe an already-cleared IED workspace. + if (_nativeFatEvidencePersistCtsByIed.Remove(deviceId, out var pending)) + { + pending.Cancel(); + pending.Dispose(); + } + + var frozen = NativeFatEvidenceDurabilitySnapshot.Capture(device, cache); + _nativeFatEvidencePersistenceCoordinator ??= + new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceHydrationService); + _nativeFatEvidencePersistenceCoordinator.Queue(frozen); + + Trace.WriteLine( + $"[FAT durability] queued frozen evidence at {reason}; ied={device.Name}; deviceId={device.DeviceId}; canonicalRows={frozen.Device.Points.Count}."); + } +} From f99790d2d4a6c318850c0e48ca4d696cfef38273 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:10:39 +0700 Subject: [PATCH 137/158] fix(fat): remove obsolete compatibility navigation button --- IoListTestingWindow.ReleaseNavigationGuard.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 IoListTestingWindow.ReleaseNavigationGuard.cs diff --git a/IoListTestingWindow.ReleaseNavigationGuard.cs b/IoListTestingWindow.ReleaseNavigationGuard.cs new file mode 100644 index 000000000..bb7e0f31a --- /dev/null +++ b/IoListTestingWindow.ReleaseNavigationGuard.cs @@ -0,0 +1,56 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Release guard for the obsolete compatibility navigation control. Native FAT now lives in +/// the Engineering workstation itself, so the old "Engineering" return button has no valid +/// user-facing purpose and can re-enter the compatibility mount/unmount path unexpectedly. +/// Keep the legacy host code available for explicit compatibility work, but remove its risky +/// navigation button from the shipped UI. +/// +public partial class IoListTestingWindow +{ + private bool _releaseObsoleteEngineeringButtonHidden; + + [ModuleInitializer] + internal static void RegisterIoListFatReleaseNavigationGuard() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(IoListFatReleaseNavigationGuard_Loaded), + handledEventsToo: true); + } + + private static void IoListFatReleaseNavigationGuard_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._releaseObsoleteEngineeringButtonHidden) + return; + + window._releaseObsoleteEngineeringButtonHidden = true; + window.Dispatcher.BeginInvoke( + new Action(() => HideObsoleteEngineeringNavigation(window)), + DispatcherPriority.Loaded); + } + + private static void HideObsoleteEngineeringNavigation(DependencyObject root) + { + if (root is Button button && + string.Equals(button.Content?.ToString()?.Trim(), "Engineering", StringComparison.OrdinalIgnoreCase)) + { + button.IsEnabled = false; + button.Focusable = false; + button.Visibility = Visibility.Collapsed; + return; + } + + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < childCount; index++) + HideObsoleteEngineeringNavigation(VisualTreeHelper.GetChild(root, index)); + } +} From a7f428cd775f533331c28bddbf8a06fa360d16b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:11:09 +0700 Subject: [PATCH 138/158] test(fat): cover teardown persistence and compatibility guard --- ...iveFatEvidenceDurabilityRegressionTests.cs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs diff --git a/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs b/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs new file mode 100644 index 000000000..ca827ba15 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs @@ -0,0 +1,181 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatEvidenceDurabilityRegressionTests +{ + [Fact] + public async Task ImmediateIedTeardown_PersistsAndRehydratesCapturedEvidenceAndPreview() + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-native-fat-durability-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var coordinator = new NativeFatEvidencePersistenceCoordinator(service); + + var before = Device("runtime-before"); + var cswiBefore = Point(before, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Closed [10]"); + var thdBefore = Point( + before, + "ThdPPV PhsBC", + "VI3p1_THDHarmonics/V_MHAI1.ThdPPV.phsBC.cVal.mag.f", + "0"); + before.Points.Add(cswiBefore); + before.Points.Add(thdBefore); + + var cache = new NativeFatIedSessionCacheState(); + cswiBefore.DeviceTimestamp = "2026-09-13T14:10:11.123+07:00"; + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + cswiBefore, + NativeFatEvidenceField.Value1, + "Closed [10]", + FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Parse("2026-09-13T14:10:11.123+07:00")); + + cswiBefore.DeviceTimestamp = "2026-09-13T14:10:19.456+07:00"; + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + cswiBefore, + NativeFatEvidenceField.Value2, + "Open [01]", + FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Parse("2026-09-13T14:10:19.456+07:00")); + + // This is the field failure sequence: freeze at capture, then Engineering removes + // the old canonical rows immediately while persistence continues in the worker. + var frozen = NativeFatEvidenceDurabilitySnapshot.Capture(before, cache); + coordinator.Queue(frozen); + before.Points.Clear(); + await coordinator.DrainAsync(before.Name); + + var after = Device("runtime-after"); + var thdAfter = Point( + after, + "ThdPPV PhsBC", + "VI3p1_THDHarmonics/V_MHAI1.ThdPPV.phsBC.cVal.mag.f", + "0"); + var cswiAfter = Point(after, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Open [01]"); + after.Points.Add(thdAfter); + after.Points.Add(cswiAfter); + + var hydration = await service.HydrateAsync(after); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); + + Assert.True(hydration.Succeeded); + Assert.True(hydration.SnapshotFound); + Assert.Equal( + "Closed [10]", + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value1)); + Assert.Equal( + "2026-09-13 14:10:11.123", + NativeFatCanonicalEvidenceOverlay.Read(restored, cswiAfter, NativeFatEvidenceField.Value1Timestamp)); + Assert.Equal( + "Open [01]", + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value2)); + Assert.Equal( + "2026-09-13 14:10:19.456", + NativeFatCanonicalEvidenceOverlay.Read(restored, cswiAfter, NativeFatEvidenceField.Value2Timestamp)); + Assert.Equal( + "OK", + NativeFatCanonicalEvidenceOverlay.Read(restored, cswiAfter, NativeFatEvidenceField.Result)); + + Assert.Equal( + string.Empty, + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, thdAfter, NativeFatEvidenceField.Value1)); + Assert.Equal( + string.Empty, + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, thdAfter, NativeFatEvidenceField.Value2)); + Assert.Equal( + string.Empty, + NativeFatCanonicalEvidenceOverlay.Read(restored, thdAfter, NativeFatEvidenceField.Result)); + + var preview = NativeFatPrintPreviewSnapshot.Capture(after, restored); + var previewCswi = Assert.Single( + preview.Rows.Where(row => row.IecTelegram.Equals(cswiAfter.IecTelegram, StringComparison.OrdinalIgnoreCase))); + Assert.Equal("Closed [10]", previewCswi.Value1); + Assert.Equal("2026-09-13 14:10:11.123", previewCswi.Value1TimestampText); + Assert.Equal("Open [01]", previewCswi.Value2); + Assert.Equal("2026-09-13 14:10:19.456", previewCswi.Value2TimestampText); + Assert.Equal("COMPLETE", previewCswi.Result); + } + finally + { + try + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + catch + { + } + } + } + + [Fact] + public void ReleaseGuard_RemovesObsoleteEngineeringCompatibilityButtonFromShippedUi() + { + var root = FindRepoRoot(); + var guard = File.ReadAllText(Path.Combine(root, "IoListTestingWindow.ReleaseNavigationGuard.cs")); + + Assert.Contains("\"Engineering\"", guard, StringComparison.Ordinal); + Assert.Contains("Visibility.Collapsed", guard, StringComparison.Ordinal); + Assert.Contains("button.IsEnabled = false", guard, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(string deviceId) + => new() + { + DeviceId = deviceId, + Name = "AA1EIF06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + Iec61850MonitorDevice device, + string signal, + string reference, + string value) + => new() + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + IpAddress = device.IpAddress, + SignalName = signal, + IecReference = reference, + IecDataType = "DbPos", + Quality = "Good", + Status = "Live", + SourceMode = "IEC 61850 report", + Value = value, + DeviceTimestamp = "2026-09-13T14:10:00.000+07:00" + }; + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From 45789fcb93c70c4568f67e3bfd5277ad27a2c16b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:11:32 +0700 Subject: [PATCH 139/158] fix(fat): keep persistence worker compile-safe --- .../IoTesting/NativeFatEvidencePersistenceCoordinator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs index 040e0d691..fee9d6c39 100644 --- a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs +++ b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs @@ -94,7 +94,7 @@ internal void Queue(NativeFatEvidenceDurabilitySnapshot snapshot) state.Latest = snapshot; state.Generation++; if (state.Worker == null || state.Worker.IsCompleted) - state.Worker = Task.Run(() => RunWorkerAsync(snapshot.StableIedName, state)); + state.Worker = Task.Run(() => RunWorkerAsync(state)); } } @@ -125,7 +125,7 @@ internal async Task DrainAsync(string iedName) } } - private async Task RunWorkerAsync(string stableIedName, IedWriteState state) + private async Task RunWorkerAsync(IedWriteState state) { while (true) { @@ -146,7 +146,7 @@ await _service Trace.WriteLine( $"[FAT durability] persisted frozen evidence; ied={snapshot.Device.Name}; generation={generation}; rows={NativeFatCanonicalEvidenceOverlay.Snapshot(snapshot.Cache).Count}."); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ObjectDisposedException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { Trace.WriteLine( $"[FAT durability] evidence persistence failed for {snapshot.Device.Name}: {ex.Message}"); From 4e10d817fbdda04b2989b7072520c097b0129eea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:14:31 +0700 Subject: [PATCH 140/158] fix(fat): import evidence session model for durability worker --- Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs index fee9d6c39..0618b2c00 100644 --- a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs +++ b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; namespace ArIED61850Tester.Services.IoTesting; From 6b7664876f8688ceac6b3a53d3fee82df34e6b9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:16:13 +0700 Subject: [PATCH 141/158] perf(fat): reuse frozen IED identity and coalesce evidence writes --- ...NativeFatEvidencePersistenceCoordinator.cs | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs index 0618b2c00..db5551782 100644 --- a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs +++ b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs @@ -24,12 +24,9 @@ private NativeFatEvidenceDurabilitySnapshot( internal NativeFatIedSessionCacheState Cache { get; } internal string StableIedName { get; } - internal static NativeFatEvidenceDurabilitySnapshot Capture( - Iec61850MonitorDevice source, - NativeFatIedSessionCacheState sourceCache) + internal static Iec61850MonitorDevice FreezeCanonicalIdentity(Iec61850MonitorDevice source) { ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(sourceCache); var detachedDevice = new Iec61850MonitorDevice { @@ -39,9 +36,8 @@ internal static NativeFatEvidenceDurabilitySnapshot Capture( Port = source.Port }; - // SaveAsync needs only canonical row identity. Copy that identity now so a later - // RemoveDevicePoints/Points.Clear cannot turn a valid evidence snapshot into an - // empty snapshot while a debounced/background write is still pending. + // SaveAsync needs only canonical row identity. Copy it once for the lifetime of this + // runtime IED so later captures copy only sparse evidence rather than thousands of rows. foreach (var point in source.Points) { detachedDevice.Points.Add(new Iec61850MonitorPoint @@ -54,22 +50,39 @@ internal static NativeFatEvidenceDurabilitySnapshot Capture( }); } + return detachedDevice; + } + + internal static NativeFatEvidenceDurabilitySnapshot Capture( + Iec61850MonitorDevice source, + NativeFatIedSessionCacheState sourceCache) + => CaptureFrozen(FreezeCanonicalIdentity(source), sourceCache); + + internal static NativeFatEvidenceDurabilitySnapshot CaptureFrozen( + Iec61850MonitorDevice frozenDevice, + NativeFatIedSessionCacheState sourceCache) + { + ArgumentNullException.ThrowIfNull(frozenDevice); + ArgumentNullException.ThrowIfNull(sourceCache); + var detachedCache = new NativeFatIedSessionCacheState(); NativeFatCanonicalEvidenceOverlay.MergeMissing( detachedCache, NativeFatCanonicalEvidenceOverlay.Snapshot(sourceCache)); - return new NativeFatEvidenceDurabilitySnapshot(detachedDevice, detachedCache); + return new NativeFatEvidenceDurabilitySnapshot(frozenDevice, detachedCache); } } /// /// Lightweight per-IED persistence worker. There is no permanent thread: a worker exists -/// only while an IED has dirty evidence. Writes for the same stable IEDName are serialized -/// and the newest generation is always persisted last. +/// only while an IED has dirty evidence. Writes for the same stable IEDName are serialized, +/// short bursts are coalesced, and the newest generation is always persisted last. /// internal sealed class NativeFatEvidencePersistenceCoordinator { + private static readonly TimeSpan CoalesceWindow = TimeSpan.FromMilliseconds(120); + private readonly NativeFatEvidenceHydrationService _service; private readonly object _gate = new(); private readonly Dictionary _stateByIed = @@ -130,6 +143,10 @@ private async Task RunWorkerAsync(IedWriteState state) { while (true) { + // Preserve the old write-throttling intent without retaining the old live-device + // race: the payload is already frozen, so IED teardown during this window is safe. + await Task.Delay(CoalesceWindow).ConfigureAwait(false); + NativeFatEvidenceDurabilitySnapshot snapshot; long generation; lock (_gate) @@ -161,8 +178,8 @@ await _service return; } - // Evidence changed while this write was in flight. Loop immediately and - // persist only the newest frozen generation after the older write completes. + // Evidence changed while this write was in flight. Loop, coalesce the burst, + // and persist only the newest frozen generation after the older write. } } } From 4d42d180814daa97b130a274c6b6a7bd9cae5ef3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:16:31 +0700 Subject: [PATCH 142/158] perf(fat): freeze canonical IED identity once per runtime session --- MainWindow.NativeFatEvidenceDurability.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/MainWindow.NativeFatEvidenceDurability.cs b/MainWindow.NativeFatEvidenceDurability.cs index 90ddcdd69..376b27dd7 100644 --- a/MainWindow.NativeFatEvidenceDurability.cs +++ b/MainWindow.NativeFatEvidenceDurability.cs @@ -18,6 +18,8 @@ public partial class MainWindow private bool _nativeFatEvidenceDurabilityInstalled; private DataGrid? _nativeFatEvidenceDurabilityGrid; private NativeFatEvidencePersistenceCoordinator? _nativeFatEvidencePersistenceCoordinator; + private readonly Dictionary _nativeFatFrozenIdentityByRuntimeIed = + new(StringComparer.OrdinalIgnoreCase); [ModuleInitializer] internal static void RegisterNativeFatEvidenceDurability() @@ -126,7 +128,14 @@ private void QueueFrozenNativeFatEvidence(string deviceId, string reason) pending.Dispose(); } - var frozen = NativeFatEvidenceDurabilitySnapshot.Capture(device, cache); + if (!_nativeFatFrozenIdentityByRuntimeIed.TryGetValue(deviceId, out var frozenDevice) || + frozenDevice.Points.Count != device.Points.Count) + { + frozenDevice = NativeFatEvidenceDurabilitySnapshot.FreezeCanonicalIdentity(device); + _nativeFatFrozenIdentityByRuntimeIed[deviceId] = frozenDevice; + } + + var frozen = NativeFatEvidenceDurabilitySnapshot.CaptureFrozen(frozenDevice, cache); _nativeFatEvidencePersistenceCoordinator ??= new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceHydrationService); _nativeFatEvidencePersistenceCoordinator.Queue(frozen); From 36cfe1dc54f17f9ad61957dde1a4fb3df4927787 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:58:28 +0700 Subject: [PATCH 143/158] fix(fat): add IED-owned sparse evidence store --- Services/IoTesting/NativeFatEvidenceStore.cs | 348 +++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 Services/IoTesting/NativeFatEvidenceStore.cs diff --git a/Services/IoTesting/NativeFatEvidenceStore.cs b/Services/IoTesting/NativeFatEvidenceStore.cs new file mode 100644 index 000000000..faaf3c0dc --- /dev/null +++ b/Services/IoTesting/NativeFatEvidenceStore.cs @@ -0,0 +1,348 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Durable sparse FAT evidence authority keyed by stable IEDName + IEC Telegram. +/// Loading never depends on Engineering Points being materialized and saving never walks +/// the live WPF-bound row collection. Start FAT is only a producer of evidence; this store +/// is available for automatic load as soon as an IED identity is known. +/// +internal sealed class NativeFatEvidenceStore : IDisposable +{ + internal const string SnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-2.0"; + private const string LegacySnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-1.0"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + private readonly string _rootDirectory; + private readonly SemaphoreSlim _ioGate = new(1, 1); + private bool _disposed; + + internal NativeFatEvidenceStore(string? rootDirectory = null) + { + _rootDirectory = string.IsNullOrWhiteSpace(rootDirectory) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "Native FAT Evidence") + : Path.GetFullPath(rootDirectory!); + } + + internal async Task LoadAsync( + string iedName, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + var normalizedIedName = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + if (normalizedIedName.Length == 0) + return NativeFatEvidenceStoreLoadResult.Empty("IEDName is empty."); + + var stopwatch = Stopwatch.StartNew(); + var preferredPath = SnapshotPath(iedName); + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var sourcePath = File.Exists(preferredPath) + ? preferredPath + : FindNewestSnapshotByIedName(iedName); + if (sourcePath == null) + { + stopwatch.Stop(); + return NativeFatEvidenceStoreLoadResult.Empty( + $"No saved FAT evidence exists yet for {iedName}.", + stopwatch.ElapsedMilliseconds); + } + + var bytes = await File.ReadAllBytesAsync(sourcePath, cancellationToken).ConfigureAwait(false); + var document = JsonSerializer.Deserialize(bytes, JsonOptions) + ?? throw new InvalidDataException("Native FAT evidence snapshot is invalid."); + + if (!string.Equals(document.Schema, SnapshotSchema, StringComparison.Ordinal) && + !string.Equals(document.Schema, LegacySnapshotSchema, StringComparison.Ordinal)) + { + throw new InvalidDataException($"Unsupported native FAT evidence schema '{document.Schema}'."); + } + + if (!string.IsNullOrWhiteSpace(document.DeviceName) && + !NativeFatCanonicalEvidenceOverlay.NormalizeIedName(document.DeviceName) + .Equals(normalizedIedName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("Native FAT evidence belongs to a different IEDName."); + } + + var loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ignored = 0; + foreach (var pair in document.EvidenceByRow ?? new Dictionary()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (pair.Value == null || IsEmpty(pair.Value) || + !TryNormalizeRowKey(pair.Key, iedName, document.DeviceName, out var stableRowKey)) + { + ignored++; + continue; + } + + loaded[stableRowKey] = CloneForPersistence(pair.Value); + } + + stopwatch.Stop(); + return new NativeFatEvidenceStoreLoadResult( + true, + true, + sourcePath, + loaded.Count, + ignored, + stopwatch.ElapsedMilliseconds, + $"Loaded {loaded.Count} FAT evidence row(s) for {iedName}.", + loaded); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + stopwatch.Stop(); + return new NativeFatEvidenceStoreLoadResult( + false, + File.Exists(preferredPath), + preferredPath, + 0, + 0, + stopwatch.ElapsedMilliseconds, + ex.Message, + new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + finally + { + _ioGate.Release(); + } + } + + internal async Task SaveAsync( + NativeFatEvidenceDurabilitySnapshot snapshot, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + ThrowIfDisposed(); + + var evidence = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in snapshot.EvidenceByRow) + { + if (!TryNormalizeRowKey(pair.Key, snapshot.IedName, snapshot.IedName, out var stableRowKey) || + pair.Value == null || IsEmpty(pair.Value)) + { + continue; + } + + evidence[stableRowKey] = CloneForPersistence(pair.Value); + } + + var document = new NativeFatEvidenceDocument + { + Schema = SnapshotSchema, + SavedAtUtc = DateTimeOffset.UtcNow, + DeviceId = snapshot.DeviceId, + DeviceName = snapshot.IedName, + IpAddress = snapshot.IpAddress, + EvidenceByRow = evidence + }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions); + var path = SnapshotPath(snapshot.IedName); + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(_rootDirectory); + var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + await File.WriteAllBytesAsync(temporary, bytes, cancellationToken).ConfigureAwait(false); + File.Move(temporary, path, true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + finally + { + _ioGate.Release(); + } + } + + internal string SnapshotPath(string iedName) + => Path.Combine(_rootDirectory, $"{SafeIedFileToken(iedName)}.native-fat-evidence.json"); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _ioGate.Dispose(); + } + + private string? FindNewestSnapshotByIedName(string iedName) + { + if (!Directory.Exists(_rootDirectory)) + return null; + + var normalized = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + foreach (var candidate in Directory + .EnumerateFiles(_rootDirectory, "*.native-fat-evidence.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(File.GetLastWriteTimeUtc)) + { + try + { + using var stream = File.OpenRead(candidate); + using var document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty("deviceName", out var name)) + continue; + if (NativeFatCanonicalEvidenceOverlay.NormalizeIedName(name.GetString()) == normalized) + return candidate; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + continue; + } + } + + return null; + } + + private static bool TryNormalizeRowKey( + string? persistedKey, + string iedName, + string? persistedIedName, + out string stableRowKey) + { + stableRowKey = string.Empty; + if (string.IsNullOrWhiteSpace(persistedKey)) + return false; + + var separator = persistedKey.IndexOf('|'); + if (separator <= 0 || separator >= persistedKey.Length - 1) + return false; + + var owner = persistedKey[..separator].Trim(); + var reference = persistedKey[(separator + 1)..].Trim(); + var normalizedIedName = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + var ownerMatchesIed = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(owner) + .Equals(normalizedIedName, StringComparison.OrdinalIgnoreCase); + var documentMatchesIed = !string.IsNullOrWhiteSpace(persistedIedName) && + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(persistedIedName) + .Equals(normalizedIedName, StringComparison.OrdinalIgnoreCase); + if (!ownerMatchesIed && !documentMatchesIed) + return false; + + var telegram = Iec61850MonitorPoint.StripIedNamePrefix(reference, iedName); + return NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(iedName, telegram, out stableRowKey); + } + + private static NativeFatEvidenceSlotState CloneForPersistence(NativeFatEvidenceSlotState source) + { + var value1 = source.Value1Evidence?.RawValue ?? source.Value1; + var value2 = source.Value2Evidence?.RawValue ?? source.Value2; + var result = source.Result?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(result) && + !string.IsNullOrWhiteSpace(value1) && + !string.IsNullOrWhiteSpace(value2)) + { + result = "COMPLETE"; + } + + return new NativeFatEvidenceSlotState + { + Value1 = value1, + Value2 = value2, + Value1Evidence = source.Value1Evidence, + Value2Evidence = source.Value2Evidence, + Result = result + }; + } + + private static bool IsEmpty(NativeFatEvidenceSlotState slot) + => string.IsNullOrWhiteSpace(slot.Value1Evidence?.RawValue ?? slot.Value1) && + string.IsNullOrWhiteSpace(slot.Value2Evidence?.RawValue ?? slot.Value2) && + string.IsNullOrWhiteSpace(slot.Result); + + private static string SafeIedFileToken(string iedName) + { + var source = string.IsNullOrWhiteSpace(iedName) ? "IED" : iedName.Trim(); + var invalid = Path.GetInvalidFileNameChars().ToHashSet(); + var builder = new StringBuilder(source.Length); + foreach (var character in source) + builder.Append(invalid.Contains(character) || character is '/' or '\\' ? '_' : character); + + var token = builder.ToString().Trim().TrimEnd('.'); + if (token.Length == 0) + token = "IED"; + + var reserved = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + if (reserved.Contains(token)) + token += "_IED"; + + if (!string.Equals(token, source, StringComparison.Ordinal)) + { + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(source)); + token += "-" + Convert.ToHexString(digest).ToLowerInvariant()[..8]; + } + + return token; + } + + private void ThrowIfDisposed() + => ObjectDisposedException.ThrowIf(_disposed, this); + + private sealed class NativeFatEvidenceDocument + { + public string Schema { get; set; } = SnapshotSchema; + public DateTimeOffset SavedAtUtc { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string DeviceName { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public Dictionary EvidenceByRow { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } +} + +internal sealed record NativeFatEvidenceStoreLoadResult( + bool Succeeded, + bool SnapshotFound, + string SourcePath, + int LoadedRows, + int IgnoredRows, + long ElapsedMilliseconds, + string Message, + IReadOnlyDictionary EvidenceByRow) +{ + internal static NativeFatEvidenceStoreLoadResult Empty(string message, long elapsedMilliseconds = 0) + => new( + true, + false, + string.Empty, + 0, + 0, + elapsedMilliseconds, + message, + new Dictionary(StringComparer.OrdinalIgnoreCase)); +} From f59cc25da60412743515c511ed35665620d122cf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:59:16 +0700 Subject: [PATCH 144/158] refactor(fat): persist detached sparse evidence per IED --- ...NativeFatEvidencePersistenceCoordinator.cs | 136 +++++++++--------- 1 file changed, 69 insertions(+), 67 deletions(-) diff --git a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs index db5551782..3f11aee7b 100644 --- a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs +++ b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs @@ -5,91 +5,77 @@ namespace ArIED61850Tester.Services.IoTesting; /// -/// Immutable FAT evidence write input captured while the Engineering IED and its canonical -/// rows are still alive. Persistence must never enumerate a live WPF-bound collection after -/// an IED has started closing/removing from the Engineering workspace. +/// Immutable sparse FAT evidence captured at the evidence-change boundary. It contains only +/// stable IED identity and detached evidence; it never retains Engineering Points or WPF rows. /// internal sealed class NativeFatEvidenceDurabilitySnapshot { private NativeFatEvidenceDurabilitySnapshot( - Iec61850MonitorDevice device, - NativeFatIedSessionCacheState cache) + string deviceId, + string iedName, + string ipAddress, + IReadOnlyDictionary evidenceByRow) { - Device = device; - Cache = cache; - StableIedName = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(device.Name); + DeviceId = deviceId; + IedName = iedName; + IpAddress = ipAddress; + StableIedName = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + EvidenceByRow = evidenceByRow; } - internal Iec61850MonitorDevice Device { get; } - internal NativeFatIedSessionCacheState Cache { get; } + internal string DeviceId { get; } + internal string IedName { get; } + internal string IpAddress { get; } internal string StableIedName { get; } - - internal static Iec61850MonitorDevice FreezeCanonicalIdentity(Iec61850MonitorDevice source) - { - ArgumentNullException.ThrowIfNull(source); - - var detachedDevice = new Iec61850MonitorDevice - { - DeviceId = source.DeviceId, - Name = source.Name, - IpAddress = source.IpAddress, - Port = source.Port - }; - - // SaveAsync needs only canonical row identity. Copy it once for the lifetime of this - // runtime IED so later captures copy only sparse evidence rather than thousands of rows. - foreach (var point in source.Points) - { - detachedDevice.Points.Add(new Iec61850MonitorPoint - { - DeviceId = point.DeviceId, - DeviceName = point.DeviceName, - IpAddress = point.IpAddress, - SignalName = point.SignalName, - IecReference = point.IecReference - }); - } - - return detachedDevice; - } + internal IReadOnlyDictionary EvidenceByRow { get; } internal static NativeFatEvidenceDurabilitySnapshot Capture( Iec61850MonitorDevice source, NativeFatIedSessionCacheState sourceCache) - => CaptureFrozen(FreezeCanonicalIdentity(source), sourceCache); - - internal static NativeFatEvidenceDurabilitySnapshot CaptureFrozen( - Iec61850MonitorDevice frozenDevice, - NativeFatIedSessionCacheState sourceCache) { - ArgumentNullException.ThrowIfNull(frozenDevice); + ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(sourceCache); - var detachedCache = new NativeFatIedSessionCacheState(); - NativeFatCanonicalEvidenceOverlay.MergeMissing( - detachedCache, - NativeFatCanonicalEvidenceOverlay.Snapshot(sourceCache)); - - return new NativeFatEvidenceDurabilitySnapshot(frozenDevice, detachedCache); + var detached = NativeFatCanonicalEvidenceOverlay.Snapshot(sourceCache) + .ToDictionary( + pair => pair.Key, + pair => Clone(pair.Value), + StringComparer.OrdinalIgnoreCase); + + return new NativeFatEvidenceDurabilitySnapshot( + source.DeviceId, + source.Name, + source.IpAddress, + detached); } + + private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) + => new() + { + Value1 = source.Value1Evidence?.RawValue ?? source.Value1, + Value2 = source.Value2Evidence?.RawValue ?? source.Value2, + Value1Evidence = source.Value1Evidence, + Value2Evidence = source.Value2Evidence, + Result = source.Result + }; } /// -/// Lightweight per-IED persistence worker. There is no permanent thread: a worker exists -/// only while an IED has dirty evidence. Writes for the same stable IEDName are serialized, -/// short bursts are coalesced, and the newest generation is always persisted last. +/// Lightweight per-IED persistence worker. No permanent thread exists: a worker is created +/// only while one stable IEDName has dirty evidence, coalesces short bursts, and serializes +/// writes so the newest generation is always the final JSON on disk. /// internal sealed class NativeFatEvidencePersistenceCoordinator { private static readonly TimeSpan CoalesceWindow = TimeSpan.FromMilliseconds(120); - private readonly NativeFatEvidenceHydrationService _service; + private readonly NativeFatEvidenceStore _store; private readonly object _gate = new(); private readonly Dictionary _stateByIed = new(StringComparer.OrdinalIgnoreCase); - internal NativeFatEvidencePersistenceCoordinator(NativeFatEvidenceHydrationService service) - => _service = service ?? throw new ArgumentNullException(nameof(service)); + internal NativeFatEvidencePersistenceCoordinator(NativeFatEvidenceStore store) + => _store = store ?? throw new ArgumentNullException(nameof(store)); internal void Queue(NativeFatEvidenceDurabilitySnapshot snapshot) { @@ -139,12 +125,32 @@ internal async Task DrainAsync(string iedName) } } + internal async Task DrainAllAsync() + { + while (true) + { + Task[] workers; + lock (_gate) + { + workers = _stateByIed.Values + .Select(state => state.Worker) + .Where(worker => worker != null) + .Cast() + .Distinct() + .ToArray(); + } + + if (workers.Length == 0) + return; + + await Task.WhenAll(workers).ConfigureAwait(false); + } + } + private async Task RunWorkerAsync(IedWriteState state) { while (true) { - // Preserve the old write-throttling intent without retaining the old live-device - // race: the payload is already frozen, so IED teardown during this window is safe. await Task.Delay(CoalesceWindow).ConfigureAwait(false); NativeFatEvidenceDurabilitySnapshot snapshot; @@ -158,16 +164,15 @@ private async Task RunWorkerAsync(IedWriteState state) try { - await _service - .SaveAsync(snapshot.Device, snapshot.Cache, CancellationToken.None) - .ConfigureAwait(false); + await _store.SaveAsync(snapshot, CancellationToken.None).ConfigureAwait(false); Trace.WriteLine( - $"[FAT durability] persisted frozen evidence; ied={snapshot.Device.Name}; generation={generation}; rows={NativeFatCanonicalEvidenceOverlay.Snapshot(snapshot.Cache).Count}."); + $"[FAT evidence store] persisted {snapshot.EvidenceByRow.Count} sparse row(s); " + + $"ied={snapshot.IedName}; generation={generation}."); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { Trace.WriteLine( - $"[FAT durability] evidence persistence failed for {snapshot.Device.Name}: {ex.Message}"); + $"[FAT evidence store] persistence failed for {snapshot.IedName}: {ex.Message}"); } lock (_gate) @@ -177,9 +182,6 @@ await _service state.Worker = null; return; } - - // Evidence changed while this write was in flight. Loop, coalesce the burst, - // and persist only the newest frozen generation after the older write. } } } From 083a93a47c5f4b066feb73a6b3ea8cca13f0913c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 15:59:57 +0700 Subject: [PATCH 145/158] fix(fat): auto-load evidence by IEDName independent of FAT arm --- MainWindow.NativeFatEvidenceDurability.cs | 177 +++++++++++++++++++--- 1 file changed, 153 insertions(+), 24 deletions(-) diff --git a/MainWindow.NativeFatEvidenceDurability.cs b/MainWindow.NativeFatEvidenceDurability.cs index 376b27dd7..c2277941e 100644 --- a/MainWindow.NativeFatEvidenceDurability.cs +++ b/MainWindow.NativeFatEvidenceDurability.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Windows; @@ -9,17 +10,17 @@ namespace ArIED61850Tester; /// -/// Release durability guard for native FAT evidence. Capture persistence is frozen at the -/// evidence event/edit boundary, before an Engineering IED can clear/remove its live rows. -/// This intentionally leaves the canonical WPF binding/recycling path unchanged. +/// Native FAT evidence lifecycle: auto-load by stable IEDName, capture-only Start FAT, +/// detached sparse persistence, and no dependency on Engineering Points during file IO. /// public partial class MainWindow { private bool _nativeFatEvidenceDurabilityInstalled; private DataGrid? _nativeFatEvidenceDurabilityGrid; + private NativeFatEvidenceStore? _nativeFatEvidenceStore; private NativeFatEvidencePersistenceCoordinator? _nativeFatEvidencePersistenceCoordinator; - private readonly Dictionary _nativeFatFrozenIdentityByRuntimeIed = - new(StringComparer.OrdinalIgnoreCase); + private CancellationTokenSource? _nativeFatEvidenceStoreLoadCts; + private long _nativeFatEvidenceStoreLoadGeneration; [ModuleInitializer] internal static void RegisterNativeFatEvidenceDurability() @@ -36,8 +37,6 @@ private static void NativeFatEvidenceDurability_MainWindowLoaded(object sender, if (sender is not MainWindow window || window._nativeFatEvidenceDurabilityInstalled) return; - // Production FAT builds its canonical grid during Loaded. Hook after that install so - // the durability handler runs in addition to, not instead of, the proven binding path. window.Dispatcher.BeginInvoke( new Action(window.InstallNativeFatEvidenceDurability), DispatcherPriority.ApplicationIdle); @@ -49,18 +48,34 @@ private void InstallNativeFatEvidenceDurability() return; _nativeFatEvidenceDurabilityInstalled = true; + _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); _nativeFatEvidencePersistenceCoordinator ??= - new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceHydrationService); + new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceStore); _nativeFatArmCoordinator.EvidenceChanged += NativeFatEvidenceDurability_EvidenceChanged; MainTabs.SelectionChanged += NativeFatEvidenceDurability_MainTabsSelectionChanged; + PropertyChanged += NativeFatEvidenceDurability_MainWindowPropertyChanged; + Closed += NativeFatEvidenceDurability_MainWindowClosed; EnsureNativeFatEvidenceDurabilityGridHook(); + + // Load the already-selected IED too. This covers SCL/IEDs opened before this + // ApplicationIdle hook was installed. + BeginNativeFatEvidenceStoreLoad(SelectedDevice); } private void NativeFatEvidenceDurability_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) { - if (ReferenceEquals(e.Source, MainTabs)) - EnsureNativeFatEvidenceDurabilityGridHook(); + if (!ReferenceEquals(e.Source, MainTabs)) + return; + + EnsureNativeFatEvidenceDurabilityGridHook(); + BeginNativeFatEvidenceStoreLoad(SelectedDevice); + } + + private void NativeFatEvidenceDurability_MainWindowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SelectedDevice)) + BeginNativeFatEvidenceStoreLoad(SelectedDevice); } private void EnsureNativeFatEvidenceDurabilityGridHook() @@ -105,8 +120,8 @@ e.EditingElement is not TextBox editor || return; } - // Make the durability guard order-independent from the existing binding handler. - // Write is idempotent for an unchanged V1/V2 value, so either handler may run first. + // Keep this order-independent from the existing binding handler. The write is + // idempotent for unchanged V1/V2 values. var cache = GetNativeFatSession(_nativeFatBoundIedKey); NativeFatCanonicalEvidenceOverlay.Write(cache, point, evidenceColumn.Field, editor.Text); cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); @@ -120,27 +135,141 @@ private void QueueFrozenNativeFatEvidence(string deviceId, string reason) if (device == null || !_nativeFatSessionByIed.TryGetValue(deviceId, out var cache)) return; - // Cancel the old 350 ms live-device debounce. Its SaveAsync would enumerate - // device.Points later and can therefore observe an already-cleared IED workspace. + // Retire the old live-device debounce for this generation. Its SaveAsync walks + // device.Points later; the new store writes only the already-detached sparse payload. if (_nativeFatEvidencePersistCtsByIed.Remove(deviceId, out var pending)) { pending.Cancel(); pending.Dispose(); } - if (!_nativeFatFrozenIdentityByRuntimeIed.TryGetValue(deviceId, out var frozenDevice) || - frozenDevice.Points.Count != device.Points.Count) + _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); + _nativeFatEvidencePersistenceCoordinator ??= + new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceStore); + + var snapshot = NativeFatEvidenceDurabilitySnapshot.Capture(device, cache); + _nativeFatEvidencePersistenceCoordinator.Queue(snapshot); + + Trace.WriteLine( + $"[FAT evidence store] queued detached sparse evidence at {reason}; " + + $"ied={device.Name}; deviceId={device.DeviceId}; evidenceRows={snapshot.EvidenceByRow.Count}."); + } + + private void BeginNativeFatEvidenceStoreLoad(Iec61850MonitorDevice? device) + { + _nativeFatEvidenceStoreLoadCts?.Cancel(); + _nativeFatEvidenceStoreLoadCts?.Dispose(); + _nativeFatEvidenceStoreLoadCts = null; + + if (device == null || string.IsNullOrWhiteSpace(device.Name)) + return; + + _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); + var generation = Interlocked.Increment(ref _nativeFatEvidenceStoreLoadGeneration); + var cts = new CancellationTokenSource(); + _nativeFatEvidenceStoreLoadCts = cts; + _ = LoadNativeFatEvidenceStoreAsync( + device.DeviceId, + device.Name, + generation, + cts.Token); + } + + private async Task LoadNativeFatEvidenceStoreAsync( + string runtimeDeviceId, + string iedName, + long generation, + CancellationToken cancellationToken) + { + try + { + _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); + var result = await _nativeFatEvidenceStore.LoadAsync(iedName, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + if (generation != _nativeFatEvidenceStoreLoadGeneration) + return; + + var cache = GetNativeFatSession(runtimeDeviceId); + if (result.Succeeded && result.SnapshotFound) + { + ReplaceNativeFatEvidenceForIed(cache, iedName, result.EvidenceByRow); + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.Resolved; + cache.EvidenceHydratedAt = DateTimeOffset.Now; + cache.EvidenceHydrationError = string.Empty; + + if (string.Equals(_nativeFatBoundIedKey, runtimeDeviceId, StringComparison.OrdinalIgnoreCase)) + { + RefreshAllVisibleNativeFatEvidenceCells(); + RefreshNativeFatEvidenceBindingRuntime(); + UpdateNativeFatArmUi( + Devices.FirstOrDefault(candidate => candidate.DeviceId.Equals(runtimeDeviceId, StringComparison.OrdinalIgnoreCase)), + $"Canonical Engineering live rows · evidence ready · {result.LoadedRows} persisted row(s) auto-loaded by IEDName"); + } + + // Reading an older hash-named snapshot is transparent. The next capture will + // write the readable IEDName file; do not rewrite merely because a tab opened. + Trace.WriteLine( + $"[FAT evidence store] auto-loaded; ied={iedName}; deviceId={runtimeDeviceId}; " + + $"rows={result.LoadedRows}; ignored={result.IgnoredRows}; source={result.SourcePath}; " + + $"elapsedMs={result.ElapsedMilliseconds}; StartFATRequired=false."); + } + else if (!result.Succeeded) + { + Trace.WriteLine( + $"[FAT evidence store] auto-load failed for {iedName}: {result.Message}"); + } + } + catch (OperationCanceledException) + { + } + } + + private static void ReplaceNativeFatEvidenceForIed( + NativeFatIedSessionCacheState cache, + string iedName, + IReadOnlyDictionary evidenceByRow) + { + var ownerPrefix = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName) + "|"; + lock (cache.EvidenceByRow) + { + foreach (var key in cache.EvidenceByRow.Keys + .Where(key => key.StartsWith(ownerPrefix, StringComparison.OrdinalIgnoreCase)) + .ToArray()) + { + cache.EvidenceByRow.Remove(key); + } + + foreach (var pair in evidenceByRow) + cache.EvidenceByRow[pair.Key] = pair.Value; + } + } + + private void NativeFatEvidenceDurability_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatEvidenceStoreLoadCts?.Cancel(); + _nativeFatEvidenceStoreLoadCts?.Dispose(); + _nativeFatEvidenceStoreLoadCts = null; + + try { - frozenDevice = NativeFatEvidenceDurabilitySnapshot.FreezeCanonicalIdentity(device); - _nativeFatFrozenIdentityByRuntimeIed[deviceId] = frozenDevice; + _nativeFatEvidencePersistenceCoordinator?.DrainAllAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[FAT evidence store] final drain failed: {ex.Message}"); } - var frozen = NativeFatEvidenceDurabilitySnapshot.CaptureFrozen(frozenDevice, cache); - _nativeFatEvidencePersistenceCoordinator ??= - new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceHydrationService); - _nativeFatEvidencePersistenceCoordinator.Queue(frozen); + _nativeFatArmCoordinator.EvidenceChanged -= NativeFatEvidenceDurability_EvidenceChanged; + MainTabs.SelectionChanged -= NativeFatEvidenceDurability_MainTabsSelectionChanged; + PropertyChanged -= NativeFatEvidenceDurability_MainWindowPropertyChanged; + Closed -= NativeFatEvidenceDurability_MainWindowClosed; + if (_nativeFatEvidenceDurabilityGrid != null) + _nativeFatEvidenceDurabilityGrid.CellEditEnding -= NativeFatEvidenceDurability_CellEditEnding; + _nativeFatEvidenceDurabilityGrid = null; - Trace.WriteLine( - $"[FAT durability] queued frozen evidence at {reason}; ied={device.Name}; deviceId={device.DeviceId}; canonicalRows={frozen.Device.Points.Count}."); + _nativeFatEvidenceStore?.Dispose(); + _nativeFatEvidenceStore = null; + _nativeFatEvidencePersistenceCoordinator = null; } } From 3203d2da8c57023d94e7714b4c3b7978df518180 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 16:00:39 +0700 Subject: [PATCH 146/158] test(fat): cover IED-owned auto-load and continued capture --- ...iveFatEvidenceDurabilityRegressionTests.cs | 91 +++++++++++++++---- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs b/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs index ca827ba15..88228adb1 100644 --- a/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs @@ -7,17 +7,17 @@ namespace ARSAS.Tests; public sealed class NativeFatEvidenceDurabilityRegressionTests { [Fact] - public async Task ImmediateIedTeardown_PersistsAndRehydratesCapturedEvidenceAndPreview() + public async Task IedOwnedStore_LoadsBeforeRowsExist_AndSurvivesImmediateTeardown() { var root = Path.Combine( Path.GetTempPath(), - "arsas-native-fat-durability-" + Guid.NewGuid().ToString("N")); + "arsas-native-fat-store-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { - using var service = new NativeFatEvidenceHydrationService(root); - var coordinator = new NativeFatEvidencePersistenceCoordinator(service); + using var store = new NativeFatEvidenceStore(root); + var coordinator = new NativeFatEvidencePersistenceCoordinator(store); var before = Device("runtime-before"); var cswiBefore = Point(before, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Closed [10]"); @@ -48,14 +48,34 @@ public async Task ImmediateIedTeardown_PersistsAndRehydratesCapturedEvidenceAndP FatEvidenceCaptureKind.AutomaticTransition, DateTimeOffset.Parse("2026-09-13T14:10:19.456+07:00")); - // This is the field failure sequence: freeze at capture, then Engineering removes - // the old canonical rows immediately while persistence continues in the worker. - var frozen = NativeFatEvidenceDurabilitySnapshot.Capture(before, cache); - coordinator.Queue(frozen); + // Capture evidence first, then tear down every Engineering row immediately. + // The worker must never need before.Points again. + coordinator.Queue(NativeFatEvidenceDurabilitySnapshot.Capture(before, cache)); before.Points.Clear(); await coordinator.DrainAsync(before.Name); + var path = store.SnapshotPath(before.Name); + Assert.Equal("AA1EIF06R4.native-fat-evidence.json", Path.GetFileName(path)); + Assert.True(File.Exists(path)); + + var json = await File.ReadAllTextAsync(path); + Assert.Contains("\"deviceName\":\"AA1EIF06R4\"", json, StringComparison.Ordinal); + Assert.Contains("\"value1\":\"Closed [10]\"", json, StringComparison.Ordinal); + Assert.Contains("\"value2\":\"Open [01]\"", json, StringComparison.Ordinal); + Assert.Contains("\"result\":\"COMPLETE\"", json, StringComparison.Ordinal); + + // Reopen creates a different runtime DeviceId. Load happens before canonical rows + // exist and therefore cannot depend on Start FAT, Points, row order or selection. var after = Device("runtime-after"); + var load = await store.LoadAsync(after.Name); + Assert.True(load.Succeeded); + Assert.True(load.SnapshotFound); + Assert.Equal(1, load.LoadedRows); + + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, load.EvidenceByRow); + + // Canonical rows materialize later from the SCL/Engineering workspace. var thdAfter = Point( after, "ThdPPV PhsBC", @@ -65,12 +85,6 @@ public async Task ImmediateIedTeardown_PersistsAndRehydratesCapturedEvidenceAndP after.Points.Add(thdAfter); after.Points.Add(cswiAfter); - var hydration = await service.HydrateAsync(after); - var restored = new NativeFatIedSessionCacheState(); - NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); - - Assert.True(hydration.Succeeded); - Assert.True(hydration.SnapshotFound); Assert.Equal( "Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value1)); @@ -86,6 +100,9 @@ public async Task ImmediateIedTeardown_PersistsAndRehydratesCapturedEvidenceAndP Assert.Equal( "OK", NativeFatCanonicalEvidenceOverlay.Read(restored, cswiAfter, NativeFatEvidenceField.Result)); + Assert.Equal( + "COMPLETE", + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Result)); Assert.Equal( string.Empty, @@ -93,9 +110,6 @@ public async Task ImmediateIedTeardown_PersistsAndRehydratesCapturedEvidenceAndP Assert.Equal( string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, thdAfter, NativeFatEvidenceField.Value2)); - Assert.Equal( - string.Empty, - NativeFatCanonicalEvidenceOverlay.Read(restored, thdAfter, NativeFatEvidenceField.Result)); var preview = NativeFatPrintPreviewSnapshot.Capture(after, restored); var previewCswi = Assert.Single( @@ -119,6 +133,49 @@ public async Task ImmediateIedTeardown_PersistsAndRehydratesCapturedEvidenceAndP } } + [Fact] + public async Task LoadedPair_StartFatDoesNotOverwriteUntilARealTransitionOccurs() + { + var device = Device("runtime-reopen"); + var point = Point(device, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Open [01]"); + device.Points.Add(point); + + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value1, + "Closed [10]", + FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Parse("2026-09-13T14:10:11.123+07:00")); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value2, + "Open [01]", + FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Parse("2026-09-13T14:10:19.456+07:00")); + + using var arm = new NativeFatArmCoordinator(); + var changes = new List(); + arm.EvidenceChanged += (_, e) => changes.Add(e); + + var armed = arm.Arm(device, cache); + Assert.True(armed.Succeeded); + Assert.Equal(0, armed.SeededValue1Rows); + Assert.Empty(changes); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + + point.Value = "Closed [10]"; + await Task.Delay(25); + + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal("OK", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result)); + Assert.NotEmpty(changes); + } + [Fact] public void ReleaseGuard_RemovesObsoleteEngineeringCompatibilityButtonFromShippedUi() { From 3768ff5ef634053dceef07ce9bb8bd69a08b2907 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 16:01:46 +0700 Subject: [PATCH 147/158] perf(fat): load each IED evidence store once per runtime session --- MainWindow.NativeFatEvidenceDurability.cs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/MainWindow.NativeFatEvidenceDurability.cs b/MainWindow.NativeFatEvidenceDurability.cs index c2277941e..d64abc877 100644 --- a/MainWindow.NativeFatEvidenceDurability.cs +++ b/MainWindow.NativeFatEvidenceDurability.cs @@ -20,6 +20,8 @@ public partial class MainWindow private NativeFatEvidenceStore? _nativeFatEvidenceStore; private NativeFatEvidencePersistenceCoordinator? _nativeFatEvidencePersistenceCoordinator; private CancellationTokenSource? _nativeFatEvidenceStoreLoadCts; + private readonly HashSet _nativeFatEvidenceStoreLoadedSessions = + new(StringComparer.OrdinalIgnoreCase); private long _nativeFatEvidenceStoreLoadGeneration; [ModuleInitializer] @@ -157,13 +159,17 @@ private void QueueFrozenNativeFatEvidence(string deviceId, string reason) private void BeginNativeFatEvidenceStoreLoad(Iec61850MonitorDevice? device) { + if (device == null || string.IsNullOrWhiteSpace(device.Name)) + return; + + var sessionKey = BuildNativeFatEvidenceStoreSessionKey(device.DeviceId, device.Name); + if (_nativeFatEvidenceStoreLoadedSessions.Contains(sessionKey)) + return; + _nativeFatEvidenceStoreLoadCts?.Cancel(); _nativeFatEvidenceStoreLoadCts?.Dispose(); _nativeFatEvidenceStoreLoadCts = null; - if (device == null || string.IsNullOrWhiteSpace(device.Name)) - return; - _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); var generation = Interlocked.Increment(ref _nativeFatEvidenceStoreLoadGeneration); var cts = new CancellationTokenSource(); @@ -171,6 +177,7 @@ private void BeginNativeFatEvidenceStoreLoad(Iec61850MonitorDevice? device) _ = LoadNativeFatEvidenceStoreAsync( device.DeviceId, device.Name, + sessionKey, generation, cts.Token); } @@ -178,6 +185,7 @@ private void BeginNativeFatEvidenceStoreLoad(Iec61850MonitorDevice? device) private async Task LoadNativeFatEvidenceStoreAsync( string runtimeDeviceId, string iedName, + string sessionKey, long generation, CancellationToken cancellationToken) { @@ -190,6 +198,9 @@ private async Task LoadNativeFatEvidenceStoreAsync( if (generation != _nativeFatEvidenceStoreLoadGeneration) return; + if (result.Succeeded) + _nativeFatEvidenceStoreLoadedSessions.Add(sessionKey); + var cache = GetNativeFatSession(runtimeDeviceId); if (result.Succeeded && result.SnapshotFound) { @@ -207,8 +218,6 @@ private async Task LoadNativeFatEvidenceStoreAsync( $"Canonical Engineering live rows · evidence ready · {result.LoadedRows} persisted row(s) auto-loaded by IEDName"); } - // Reading an older hash-named snapshot is transparent. The next capture will - // write the readable IEDName file; do not rewrite merely because a tab opened. Trace.WriteLine( $"[FAT evidence store] auto-loaded; ied={iedName}; deviceId={runtimeDeviceId}; " + $"rows={result.LoadedRows}; ignored={result.IgnoredRows}; source={result.SourcePath}; " + @@ -225,6 +234,9 @@ private async Task LoadNativeFatEvidenceStoreAsync( } } + private static string BuildNativeFatEvidenceStoreSessionKey(string deviceId, string iedName) + => $"{deviceId.Trim().ToLowerInvariant()}|{NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName)}"; + private static void ReplaceNativeFatEvidenceForIed( NativeFatIedSessionCacheState cache, string iedName, @@ -267,6 +279,7 @@ private void NativeFatEvidenceDurability_MainWindowClosed(object? sender, EventA if (_nativeFatEvidenceDurabilityGrid != null) _nativeFatEvidenceDurabilityGrid.CellEditEnding -= NativeFatEvidenceDurability_CellEditEnding; _nativeFatEvidenceDurabilityGrid = null; + _nativeFatEvidenceStoreLoadedSessions.Clear(); _nativeFatEvidenceStore?.Dispose(); _nativeFatEvidenceStore = null; From 0416a983934855975818ccb0fe2db51a0816abc5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 16:03:57 +0700 Subject: [PATCH 148/158] fix(fat): keep evidence snapshot valid after IED detach --- .../NativeFatEvidencePersistenceCoordinator.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs index 3f11aee7b..f0a57f0fe 100644 --- a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs +++ b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs @@ -34,6 +34,15 @@ internal static NativeFatEvidenceDurabilitySnapshot Capture( NativeFatIedSessionCacheState sourceCache) { ArgumentNullException.ThrowIfNull(source); + return Capture(source.DeviceId, source.Name, source.IpAddress, sourceCache); + } + + internal static NativeFatEvidenceDurabilitySnapshot Capture( + string deviceId, + string iedName, + string ipAddress, + NativeFatIedSessionCacheState sourceCache) + { ArgumentNullException.ThrowIfNull(sourceCache); var detached = NativeFatCanonicalEvidenceOverlay.Snapshot(sourceCache) @@ -43,9 +52,9 @@ internal static NativeFatEvidenceDurabilitySnapshot Capture( StringComparer.OrdinalIgnoreCase); return new NativeFatEvidenceDurabilitySnapshot( - source.DeviceId, - source.Name, - source.IpAddress, + deviceId?.Trim() ?? string.Empty, + iedName?.Trim() ?? string.Empty, + ipAddress?.Trim() ?? string.Empty, detached); } From 41bd8b1a1537d341d5bba94b2a595a0a193a9071 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 16:04:27 +0700 Subject: [PATCH 149/158] fix(fat): persist capture even after rapid IED removal --- MainWindow.NativeFatEvidenceDurability.cs | 32 ++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/MainWindow.NativeFatEvidenceDurability.cs b/MainWindow.NativeFatEvidenceDurability.cs index d64abc877..b758f6fd2 100644 --- a/MainWindow.NativeFatEvidenceDurability.cs +++ b/MainWindow.NativeFatEvidenceDurability.cs @@ -106,7 +106,11 @@ private void NativeFatEvidenceDurability_EvidenceChanged( return; } - QueueFrozenNativeFatEvidence(e.DeviceId, "capture"); + QueueFrozenNativeFatEvidence( + e.DeviceId, + "capture", + e.Point.DeviceName, + e.Point.IpAddress); } private void NativeFatEvidenceDurability_CellEditEnding( @@ -127,14 +131,27 @@ e.EditingElement is not TextBox editor || var cache = GetNativeFatSession(_nativeFatBoundIedKey); NativeFatCanonicalEvidenceOverlay.Write(cache, point, evidenceColumn.Field, editor.Text); cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); - QueueFrozenNativeFatEvidence(_nativeFatBoundIedKey, "operator edit"); + QueueFrozenNativeFatEvidence( + _nativeFatBoundIedKey, + "operator edit", + point.DeviceName, + point.IpAddress); } - private void QueueFrozenNativeFatEvidence(string deviceId, string reason) + private void QueueFrozenNativeFatEvidence( + string deviceId, + string reason, + string? fallbackIedName = null, + string? fallbackIpAddress = null) { + if (!_nativeFatSessionByIed.TryGetValue(deviceId, out var cache)) + return; + var device = Devices.FirstOrDefault(candidate => candidate.DeviceId.Equals(deviceId, StringComparison.OrdinalIgnoreCase)); - if (device == null || !_nativeFatSessionByIed.TryGetValue(deviceId, out var cache)) + var iedName = device?.Name ?? fallbackIedName ?? string.Empty; + var ipAddress = device?.IpAddress ?? fallbackIpAddress ?? string.Empty; + if (string.IsNullOrWhiteSpace(iedName)) return; // Retire the old live-device debounce for this generation. Its SaveAsync walks @@ -149,12 +166,15 @@ private void QueueFrozenNativeFatEvidence(string deviceId, string reason) _nativeFatEvidencePersistenceCoordinator ??= new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceStore); - var snapshot = NativeFatEvidenceDurabilitySnapshot.Capture(device, cache); + var snapshot = device != null + ? NativeFatEvidenceDurabilitySnapshot.Capture(device, cache) + : NativeFatEvidenceDurabilitySnapshot.Capture(deviceId, iedName, ipAddress, cache); _nativeFatEvidencePersistenceCoordinator.Queue(snapshot); Trace.WriteLine( $"[FAT evidence store] queued detached sparse evidence at {reason}; " + - $"ied={device.Name}; deviceId={device.DeviceId}; evidenceRows={snapshot.EvidenceByRow.Count}."); + $"ied={iedName}; deviceId={deviceId}; evidenceRows={snapshot.EvidenceByRow.Count}; " + + $"liveDevicePresent={device != null}."); } private void BeginNativeFatEvidenceStoreLoad(Iec61850MonitorDevice? device) From ba5c8e15fadaf43659812426cf3b737d77f2a853 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 16:06:30 +0700 Subject: [PATCH 150/158] perf(fat): prepare evidence independently for every opened IED --- MainWindow.NativeFatEvidenceDurability.cs | 68 +++++++++++++++-------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/MainWindow.NativeFatEvidenceDurability.cs b/MainWindow.NativeFatEvidenceDurability.cs index b758f6fd2..81eaf540b 100644 --- a/MainWindow.NativeFatEvidenceDurability.cs +++ b/MainWindow.NativeFatEvidenceDurability.cs @@ -1,3 +1,4 @@ +using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics; using System.Runtime.CompilerServices; @@ -19,10 +20,10 @@ public partial class MainWindow private DataGrid? _nativeFatEvidenceDurabilityGrid; private NativeFatEvidenceStore? _nativeFatEvidenceStore; private NativeFatEvidencePersistenceCoordinator? _nativeFatEvidencePersistenceCoordinator; - private CancellationTokenSource? _nativeFatEvidenceStoreLoadCts; + private readonly Dictionary _nativeFatEvidenceStoreLoadCtsBySession = + new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _nativeFatEvidenceStoreLoadedSessions = new(StringComparer.OrdinalIgnoreCase); - private long _nativeFatEvidenceStoreLoadGeneration; [ModuleInitializer] internal static void RegisterNativeFatEvidenceDurability() @@ -55,14 +56,27 @@ private void InstallNativeFatEvidenceDurability() new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceStore); _nativeFatArmCoordinator.EvidenceChanged += NativeFatEvidenceDurability_EvidenceChanged; + Devices.CollectionChanged += NativeFatEvidenceDurability_DevicesCollectionChanged; MainTabs.SelectionChanged += NativeFatEvidenceDurability_MainTabsSelectionChanged; PropertyChanged += NativeFatEvidenceDurability_MainWindowPropertyChanged; Closed += NativeFatEvidenceDurability_MainWindowClosed; EnsureNativeFatEvidenceDurabilityGridHook(); - // Load the already-selected IED too. This covers SCL/IEDs opened before this - // ApplicationIdle hook was installed. - BeginNativeFatEvidenceStoreLoad(SelectedDevice); + // Prepare every IED already present in the Engineering workspace. This is intentionally + // independent of FAT tab activation and Start FAT, and also covers multi-IED SCL files. + foreach (var device in Devices) + BeginNativeFatEvidenceStoreLoad(device); + } + + private void NativeFatEvidenceDurability_DevicesCollectionChanged( + object? sender, + NotifyCollectionChangedEventArgs e) + { + if (e.NewItems == null) + return; + + foreach (var device in e.NewItems.OfType()) + BeginNativeFatEvidenceStoreLoad(device); } private void NativeFatEvidenceDurability_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) @@ -183,40 +197,33 @@ private void BeginNativeFatEvidenceStoreLoad(Iec61850MonitorDevice? device) return; var sessionKey = BuildNativeFatEvidenceStoreSessionKey(device.DeviceId, device.Name); - if (_nativeFatEvidenceStoreLoadedSessions.Contains(sessionKey)) + if (_nativeFatEvidenceStoreLoadedSessions.Contains(sessionKey) || + _nativeFatEvidenceStoreLoadCtsBySession.ContainsKey(sessionKey)) + { return; - - _nativeFatEvidenceStoreLoadCts?.Cancel(); - _nativeFatEvidenceStoreLoadCts?.Dispose(); - _nativeFatEvidenceStoreLoadCts = null; + } _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); - var generation = Interlocked.Increment(ref _nativeFatEvidenceStoreLoadGeneration); var cts = new CancellationTokenSource(); - _nativeFatEvidenceStoreLoadCts = cts; + _nativeFatEvidenceStoreLoadCtsBySession[sessionKey] = cts; _ = LoadNativeFatEvidenceStoreAsync( device.DeviceId, device.Name, sessionKey, - generation, - cts.Token); + cts); } private async Task LoadNativeFatEvidenceStoreAsync( string runtimeDeviceId, string iedName, string sessionKey, - long generation, - CancellationToken cancellationToken) + CancellationTokenSource owner) { try { _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); - var result = await _nativeFatEvidenceStore.LoadAsync(iedName, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - - if (generation != _nativeFatEvidenceStoreLoadGeneration) - return; + var result = await _nativeFatEvidenceStore.LoadAsync(iedName, owner.Token); + owner.Token.ThrowIfCancellationRequested(); if (result.Succeeded) _nativeFatEvidenceStoreLoadedSessions.Add(sessionKey); @@ -252,6 +259,15 @@ private async Task LoadNativeFatEvidenceStoreAsync( catch (OperationCanceledException) { } + finally + { + if (_nativeFatEvidenceStoreLoadCtsBySession.TryGetValue(sessionKey, out var current) && + ReferenceEquals(current, owner)) + { + _nativeFatEvidenceStoreLoadCtsBySession.Remove(sessionKey); + owner.Dispose(); + } + } } private static string BuildNativeFatEvidenceStoreSessionKey(string deviceId, string iedName) @@ -279,9 +295,12 @@ private static void ReplaceNativeFatEvidenceForIed( private void NativeFatEvidenceDurability_MainWindowClosed(object? sender, EventArgs e) { - _nativeFatEvidenceStoreLoadCts?.Cancel(); - _nativeFatEvidenceStoreLoadCts?.Dispose(); - _nativeFatEvidenceStoreLoadCts = null; + foreach (var cts in _nativeFatEvidenceStoreLoadCtsBySession.Values) + { + cts.Cancel(); + cts.Dispose(); + } + _nativeFatEvidenceStoreLoadCtsBySession.Clear(); try { @@ -293,6 +312,7 @@ private void NativeFatEvidenceDurability_MainWindowClosed(object? sender, EventA } _nativeFatArmCoordinator.EvidenceChanged -= NativeFatEvidenceDurability_EvidenceChanged; + Devices.CollectionChanged -= NativeFatEvidenceDurability_DevicesCollectionChanged; MainTabs.SelectionChanged -= NativeFatEvidenceDurability_MainTabsSelectionChanged; PropertyChanged -= NativeFatEvidenceDurability_MainWindowPropertyChanged; Closed -= NativeFatEvidenceDurability_MainWindowClosed; From 15c7692bfa4e381a7592fa8d86e907b93b588e3b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 16:09:54 +0700 Subject: [PATCH 151/158] fix(fat): defer SCL evidence load until IED identity is applied --- MainWindow.NativeFatEvidenceDurability.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/MainWindow.NativeFatEvidenceDurability.cs b/MainWindow.NativeFatEvidenceDurability.cs index 81eaf540b..214395884 100644 --- a/MainWindow.NativeFatEvidenceDurability.cs +++ b/MainWindow.NativeFatEvidenceDurability.cs @@ -76,7 +76,15 @@ private void NativeFatEvidenceDurability_DevicesCollectionChanged( return; foreach (var device in e.NewItems.OfType()) - BeginNativeFatEvidenceStoreLoad(device); + { + var addedDevice = device; + // Open SCL inserts a new device before ApplySclWorkspaceToDevice assigns the + // authoritative workspace.IedName. Defer one dispatcher turn so the store lookup + // never runs against the constructor placeholder name "IED". + Dispatcher.BeginInvoke( + new Action(() => BeginNativeFatEvidenceStoreLoad(addedDevice)), + DispatcherPriority.Background); + } } private void NativeFatEvidenceDurability_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) From be0d867fc79a8a4958eb86e11958e148a51a111f Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 13 Sep 2026 18:15:49 +0700 Subject: [PATCH 152/158] fix(fat): remove obsolete workspace switcher --- MainWindow.FieldPresentationFix.cs | 38 ++- MainWindow.NativeFatP4CColumnContract.cs | 10 +- MainWindow.NavigationLayoutFix.cs | 66 +---- MainWindow.PersistentWorkbench.cs | 6 +- MainWindow.WorkspaceModeSwitch.cs | 225 ------------------ tests/ARSAS.Tests/FieldRegressionFixTests.cs | 52 +++- .../IoFatSclAppendWorkflowRegressionTests.cs | 23 +- .../MainWindowTopBarLayoutRegressionTests.cs | 10 +- ...ativeFatP4CCanonicalColumnContractTests.cs | 5 + ...uctionFatP1CanonicalGridRegressionTests.cs | 2 +- tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs | 33 +-- 11 files changed, 132 insertions(+), 338 deletions(-) diff --git a/MainWindow.FieldPresentationFix.cs b/MainWindow.FieldPresentationFix.cs index 9d15f5781..99097c04f 100644 --- a/MainWindow.FieldPresentationFix.cs +++ b/MainWindow.FieldPresentationFix.cs @@ -19,6 +19,9 @@ internal static class MainWindowFieldPresentationFix { private const string IedTimestampHeader = "IED Timestamp"; private const string SignalHeader = "Signal"; + internal static SolidColorBrush CommandTargetForegroundBrush { get; } = FrozenBrush(0x58, 0x6B, 0x82); + internal static SolidColorBrush CommandTargetBackgroundBrush { get; } = FrozenBrush(0xF4, 0xF7, 0xFB); + internal static SolidColorBrush CommandTargetBorderBrush { get; } = FrozenBrush(0xD6, 0xE0, 0xEC); [ModuleInitializer] internal static void Register() @@ -148,12 +151,36 @@ private static void ApplySemanticSignalColumns(MainWindow window) private static void ApplyDarkCommandHeaderContrast(MainWindow window) { - if (window.FindName("CommandPanelExpander") is not Expander expander || expander.Header is not DependencyObject header) + if (window.FindName("CommandPanelExpander") is not Expander expander) + return; + + ApplyDarkCommandHeaderContrast(expander); + } + + internal static void ApplyDarkCommandHeaderContrast(Expander expander) + { + if (expander.Header is not DependencyObject header) return; expander.Foreground = Brushes.White; + var targetBadge = VisualDescendants(header) + .FirstOrDefault(border => Equals(border.Tag, "P0CommandTargetBadge")); + var targetTexts = targetBadge == null + ? new HashSet() + : VisualDescendants(targetBadge).ToHashSet(); + + if (targetBadge != null) + { + targetBadge.Background = CommandTargetBackgroundBrush; + targetBadge.BorderBrush = CommandTargetBorderBrush; + } + foreach (var text in VisualDescendants(header).Prepend(header as TextBlock).OfType()) - text.Foreground = Brushes.White; + { + text.Foreground = targetTexts.Contains(text) + ? CommandTargetForegroundBrush + : Brushes.White; + } } /// @@ -216,6 +243,13 @@ private static IEnumerable VisualDescendants(DependencyObject root) where } } + private static SolidColorBrush FrozenBrush(byte red, byte green, byte blue) + { + var brush = new SolidColorBrush(Color.FromRgb(red, green, blue)); + brush.Freeze(); + return brush; + } + private sealed class RoundedIedTimestampConverter : IValueConverter { internal static readonly RoundedIedTimestampConverter Instance = new(); diff --git a/MainWindow.NativeFatP4CColumnContract.cs b/MainWindow.NativeFatP4CColumnContract.cs index 14037be29..889e03dbe 100644 --- a/MainWindow.NativeFatP4CColumnContract.cs +++ b/MainWindow.NativeFatP4CColumnContract.cs @@ -36,29 +36,29 @@ private void ApplyNativeFatP4CColumnContract() _nativeFatCanonicalGrid.FrozenColumnCount = 2; AddCanonicalTextColumn("Signal", nameof(Iec61850MonitorPoint.SignalName), 190); - AddCanonicalTextColumn("IEC Telegram", nameof(Iec61850MonitorPoint.IecTelegram), 320); + AddCanonicalTextColumn("IEC Telegram", nameof(Iec61850MonitorPoint.IecTelegram), 370); AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 95); - AddCanonicalTemplateColumn("Live Value", "ProcessValueBadgeTemplate", 125); + AddCanonicalTemplateColumn("Live Value", "ProcessValueBadgeTemplate", 150); // P0 field hardening: evidence text is bound to the current DataContext. WPF row // recycling therefore re-evaluates IEDName + IEC Telegram for the newly assigned // canonical point instead of carrying imperative TextBlock.Text from a previous row. _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceBindingColumn("Value 1", NativeFatEvidenceField.Value1, 120, ReadNativeFatEvidence)); + new NativeFatEvidenceBindingColumn("Value 1", NativeFatEvidenceField.Value1, 155, ReadNativeFatEvidence)); _nativeFatCanonicalGrid.Columns.Add( new NativeFatEvidenceBindingColumn("V1 Timestamp", NativeFatEvidenceField.Value1Timestamp, 185, ReadNativeFatEvidence) { IsReadOnly = true }); _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceBindingColumn("Value 2", NativeFatEvidenceField.Value2, 120, ReadNativeFatEvidence)); + new NativeFatEvidenceBindingColumn("Value 2", NativeFatEvidenceField.Value2, 155, ReadNativeFatEvidence)); _nativeFatCanonicalGrid.Columns.Add( new NativeFatEvidenceBindingColumn("V2 Timestamp", NativeFatEvidenceField.Value2Timestamp, 185, ReadNativeFatEvidence) { IsReadOnly = true }); _nativeFatCanonicalGrid.Columns.Add( - new NativeFatEvidenceBindingColumn("Result", NativeFatEvidenceField.Result, 110, ReadNativeFatEvidence)); + new NativeFatEvidenceBindingColumn("Result", NativeFatEvidenceField.Result, 68, ReadNativeFatEvidence)); } private void NativeFatObservationStatus_EvidenceChanged(object? sender, NativeFatEvidenceChangedEventArgs e) diff --git a/MainWindow.NavigationLayoutFix.cs b/MainWindow.NavigationLayoutFix.cs index 55e5b0f0b..2051bf249 100644 --- a/MainWindow.NavigationLayoutFix.cs +++ b/MainWindow.NavigationLayoutFix.cs @@ -12,8 +12,8 @@ namespace ArIED61850Tester; /// /// The original XAML used a 760 px shell split into seven equal columns while the /// selection pill moved in hard-coded 150 px steps. That was barely large enough for -/// short labels and clipped "IEC 61850 Explorer" / "GOOSE Subscriber" once the center -/// workspace switch and live connection/status chips were also present. This behavior +/// short labels and clipped "IEC 61850 Explorer" / "GOOSE Subscriber" once connection +/// and status chips were also present. This behavior /// keeps the header single-line at normal desktop sizes, deliberately compacts labels /// at smaller widths, and derives the selection pill from the real nav cell width. /// @@ -62,7 +62,7 @@ private static void OnMainWindowLoaded(object sender, RoutedEventArgs e) if (sender is not MainWindow window) return; - // Loaded may be raised again after window hide/show (e.g. IO List FAT switch). + // Loaded may be raised again after a window hide/show lifecycle. // Remove first so the responsive hooks always exist exactly once. window.SizeChanged -= MainWindow_SizeChanged; window.SizeChanged += MainWindow_SizeChanged; @@ -76,9 +76,8 @@ private static void OnMainWindowLoaded(object sender, RoutedEventArgs e) ApplyResponsiveLayout(window); QueuePillCorrection(window, animate: false); - // WorkspaceModeSwitch is installed by a separate Loaded class handler. Run one - // deferred pass so its dynamically inserted controls are included regardless of - // module/class-handler registration order. + // Run one deferred pass after all Loaded handlers have finished so the final + // navigation geometry is based on the materialized header. window.Dispatcher.BeginInvoke( DispatcherPriority.ContextIdle, new Action(() => @@ -111,19 +110,6 @@ private static void MainWindow_SizeChanged(object sender, SizeChangedEventArgs e QueuePillCorrection(window, animate: false); } - private static void WorkspaceModeChild_SizeChanged(object sender, SizeChangedEventArgs e) - { - if (sender is not FrameworkElement element || Window.GetWindow(element) is not MainWindow window) - return; - - // The FAT button can change to "... LOADED" after the window is already shown. - // Re-apply the current breakpoint so that state text is compacted intentionally - // instead of making the top bar overflow. - window.Dispatcher.BeginInvoke( - DispatcherPriority.Loaded, - new Action(() => ApplyResponsiveLayout(window))); - } - private static void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (sender is not TabControl tabs || !ReferenceEquals(e.Source, tabs)) @@ -184,7 +170,6 @@ private static void ApplyResponsiveLayout(MainWindow window) } UpdatePillGeometry(window, shellWidth); - ApplyWorkspaceSwitchDensity(window, wide, medium); } private static Button?[] GetNavigationButtons(MainWindow window) @@ -215,47 +200,6 @@ private static void UpdatePillGeometry(MainWindow window, double shellWidth) pill.ClipToBounds = false; } - private static void ApplyWorkspaceSwitchDensity(MainWindow window, bool wide, bool medium) - { - // WorkspaceModeSwitch is inserted dynamically into header column 1. At wide - // desktop widths retain the descriptive labels. At compact widths reduce only - // those redundant mode labels; the actual workspace functions remain present. - if (window.Content is not Grid root) - return; - - var header = root.Children.OfType().FirstOrDefault(child => Grid.GetRow(child) == 0); - if (header == null) - return; - - var modeShell = header.Children.OfType() - .FirstOrDefault(child => Equals(child.Tag, "ARSAS_WORKSPACE_MODE_SWITCH")) as Border; - if (modeShell?.Child is not StackPanel modes) - return; - - modeShell.Margin = new Thickness(wide ? 10 : 6, 0, wide ? 10 : 6, 0); - - if (modes.Children.Count > 0 && modes.Children[0] is Border engineering && - engineering.Child is TextBlock engineeringText) - { - engineeringText.Text = medium ? "ENGINEERING" : "ENG"; - engineering.Padding = new Thickness(medium ? 12 : 9, 7, medium ? 12 : 9, 7); - } - - if (modes.Children.Count > 1 && modes.Children[1] is Button fatButton) - { - fatButton.SizeChanged -= WorkspaceModeChild_SizeChanged; - fatButton.SizeChanged += WorkspaceModeChild_SizeChanged; - - // Do not overwrite the LOADED state used by WorkspaceModeSwitch; compact it - // while preserving that state signal. - var loaded = fatButton.Content?.ToString()?.Contains("LOADED", StringComparison.OrdinalIgnoreCase) == true; - fatButton.Content = medium - ? loaded ? "IO LIST FAT · LOADED" : "IO LIST FAT" - : loaded ? "FAT · LOADED" : "FAT"; - fatButton.Padding = new Thickness(medium ? 12 : 9, 7, medium ? 12 : 9, 7); - } - } - private static void QueuePillCorrection(MainWindow window, bool animate) { window.Dispatcher.BeginInvoke( diff --git a/MainWindow.PersistentWorkbench.cs b/MainWindow.PersistentWorkbench.cs index 6ba326562..0f95df6bc 100644 --- a/MainWindow.PersistentWorkbench.cs +++ b/MainWindow.PersistentWorkbench.cs @@ -222,14 +222,14 @@ private TextBlock DecorateP0CommandDockHeader() Text = "TARGET · NONE", FontSize = 10.2, FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(0x58, 0x6B, 0x82)), + Foreground = MainWindowFieldPresentationFix.CommandTargetForegroundBrush, VerticalAlignment = VerticalAlignment.Center }; var targetBadge = new Border { Tag = "P0CommandTargetBadge", - Background = new SolidColorBrush(Color.FromRgb(0xF4, 0xF7, 0xFB)), - BorderBrush = new SolidColorBrush(Color.FromRgb(0xD6, 0xE0, 0xEC)), + Background = MainWindowFieldPresentationFix.CommandTargetBackgroundBrush, + BorderBrush = MainWindowFieldPresentationFix.CommandTargetBorderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(7), Padding = new Thickness(7, 3, 7, 3), diff --git a/MainWindow.WorkspaceModeSwitch.cs b/MainWindow.WorkspaceModeSwitch.cs index 71983c289..6ab35c25b 100644 --- a/MainWindow.WorkspaceModeSwitch.cs +++ b/MainWindow.WorkspaceModeSwitch.cs @@ -2,110 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 using System.Windows; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Input; -using System.Windows.Media; using System.Windows.Threading; namespace ArIED61850Tester; public partial class MainWindow { - private const string WorkspaceModeSwitchTag = "ARSAS_WORKSPACE_MODE_SWITCH"; - private static readonly bool WorkspaceModeSwitchRegistered = RegisterWorkspaceModeSwitch(); - private Button? _workspaceFatButton; private IoListTestingWindow? _loadedIoFatWindow; - private static bool RegisterWorkspaceModeSwitch() - { - EventManager.RegisterClassHandler( - typeof(MainWindow), - FrameworkElement.LoadedEvent, - new RoutedEventHandler(WorkspaceModeSwitch_Loaded)); - return true; - } - - private static void WorkspaceModeSwitch_Loaded(object sender, RoutedEventArgs e) - { - if (sender is MainWindow window) - window.InstallWorkspaceModeSwitch(); - } - - private void InstallWorkspaceModeSwitch() - { - if (Content is not Grid root) - return; - - var header = root.Children.OfType().FirstOrDefault(child => Grid.GetRow(child) == 0); - if (header == null || header.Children.OfType() - .Any(child => Equals(child.Tag, WorkspaceModeSwitchTag))) - return; - - var shell = new Border - { - Tag = WorkspaceModeSwitchTag, - Background = WorkspaceBrush("#E7ECF5"), - BorderBrush = WorkspaceBrush("#D5DEEB"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(16), - Padding = new Thickness(4), - Margin = new Thickness(10, 0, 10, 0), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - ToolTip = "Switch between Engineering and IO List FAT workspaces" - }; - Grid.SetColumn(shell, 1); - - var modes = new StackPanel { Orientation = Orientation.Horizontal }; - modes.Children.Add(new Border - { - Background = TryFindResource("Accent") as Brush ?? WorkspaceBrush("#2563EB"), - CornerRadius = new CornerRadius(12), - Padding = new Thickness(12, 7, 12, 7), - Child = new TextBlock - { - Text = "ENGINEERING", - Foreground = Brushes.White, - FontSize = 10.5, - FontWeight = FontWeights.Bold, - VerticalAlignment = VerticalAlignment.Center - } - }); - - _workspaceFatButton = new Button - { - Content = "IO LIST FAT", - Style = TryFindResource("SoftButton") as Style, - Padding = new Thickness(12, 7, 12, 7), - Margin = new Thickness(4, 0, 0, 0), - FontSize = 10.5, - FontWeight = FontWeights.Bold, - Cursor = Cursors.Hand, - ToolTip = "Open or return to the IO List FAT workspace" - }; - _workspaceFatButton.Click += OpenOrResumeIoFatWorkspace_Click; - modes.Children.Add(_workspaceFatButton); - - var menuButton = new Button - { - Content = "▾", - Style = TryFindResource("SoftButton") as Style, - Padding = new Thickness(8, 7, 8, 7), - Margin = new Thickness(2, 0, 0, 0), - FontSize = 10.5, - FontWeight = FontWeights.Bold, - Cursor = Cursors.Hand, - ToolTip = "Load SCL/CID, IO List workbook, or portable ARSAS project" - }; - menuButton.Click += OpenIoFatWorkspaceMenu_Click; - modes.Children.Add(menuButton); - - shell.Child = modes; - header.Children.Add(shell); - UpdateIoFatWorkspaceModeState(); - } - internal void RegisterLoadedIoFatWindow(IoListTestingWindow window) { ArgumentNullException.ThrowIfNull(window); @@ -117,7 +21,6 @@ internal void RegisterLoadedIoFatWindow(IoListTestingWindow window) _loadedIoFatWindow = window; _loadedIoFatWindow.Closed += LoadedIoFatWindow_Closed; - UpdateIoFatWorkspaceModeState(); } internal void ShowEngineeringWorkspaceFromFat(IoListTestingWindow window) @@ -133,114 +36,6 @@ internal void ShowEngineeringWorkspaceFromFat(IoListTestingWindow window) WindowState = WindowState.Normal; Activate(); SetStatus($"Engineering workspace active · IO List FAT project '{window.Project.ProjectName}' remains loaded."); - UpdateIoFatWorkspaceModeState(); - } - - private async void OpenOrResumeIoFatWorkspace_Click(object sender, RoutedEventArgs e) - { - if (ShowLoadedIoFatWorkspace()) - return; - - // Engineering and FAT are two views over the same imported SCL workspace. If an - // Engineering SCL is already open, the primary FAT mode button projects that exact - // source and its existing checkbox authority without asking for another import. - var sharedSources = CurrentEngineeringSclSourcePaths(); - if (sharedSources.Length > 0) - { - await OpenSclFatSourcesAsync(sharedSources, selectionMode: null); - return; - } - - if (sender is Button anchor) - OpenIoFatWorkspaceMenu(anchor); - } - - private bool ShowLoadedIoFatWorkspace() - { - var window = _loadedIoFatWindow; - if (window == null || !window.IsLoaded) - return false; - - SetStatus($"Returning to loaded IO List FAT project '{window.Project.ProjectName}'."); - IsEnabled = false; - Hide(); - window.Show(); - if (window.WindowState == WindowState.Minimized) - window.WindowState = WindowState.Normal; - window.Activate(); - return true; - } - - private void OpenIoFatWorkspaceMenu_Click(object sender, RoutedEventArgs e) - { - if (sender is Button anchor) - OpenIoFatWorkspaceMenu(anchor); - } - - private void OpenIoFatWorkspaceMenu(Button anchor) - { - var menu = new ContextMenu - { - PlacementTarget = anchor, - Placement = PlacementMode.Bottom, - VerticalOffset = 5, - StaysOpen = false - }; - - if (_loadedIoFatWindow is { IsLoaded: true } loaded) - { - var resume = new MenuItem - { - Header = $"Continue loaded FAT project · {loaded.Project.ProjectName}", - FontWeight = FontWeights.SemiBold - }; - resume.Click += (_, _) => ShowLoadedIoFatWorkspace(); - menu.Items.Add(resume); - menu.Items.Add(new Separator()); - } - - var importScl = new MenuItem - { - Header = _loadedIoFatWindow is { IsLoaded: true } - ? "Add SCL / CID to loaded FAT workspace" - : "Import SCL / CID files" - }; - importScl.Click += (_, _) => - { - if (_loadedIoFatWindow is { IsLoaded: true } loaded) - { - // P0.4: SCL is additive while a FAT workspace is loaded. Existing IED - // connections/session evidence stay alive; replacement is reserved for - // explicit workbook/project open flows below. - _ = OpenSclForLoadedFatAppendAsync(loaded); - return; - } - - OpenSclFatTesting_Click(this, new RoutedEventArgs()); - }; - - var importWorkbook = new MenuItem - { - Header = _loadedIoFatWindow == null - ? "Import IO List Excel workbook" - : "Import another IO List Excel workbook" - }; - importWorkbook.Click += (_, _) => QueueIoFatWorkspaceReplacement( - () => OpenIoListTesting_Click(this, new RoutedEventArgs())); - - var openProject = new MenuItem - { - Header = _loadedIoFatWindow == null - ? "Open portable .arsas project" - : "Open another portable .arsas project" - }; - openProject.Click += (_, _) => QueueIoFatWorkspaceReplacement( - () => OpenIoListPackage_Click(this, new RoutedEventArgs())); - - menu.Items.Add(importScl); - menu.Items.Add(importWorkbook); - menu.Items.Add(openProject); - menu.IsOpen = true; } private void QueueIoFatWorkspaceReplacement(Action openReplacement) @@ -277,25 +72,5 @@ private void LoadedIoFatWindow_Closed(object? sender, EventArgs e) if (ReferenceEquals(_loadedIoFatWindow, sender)) _loadedIoFatWindow = null; IsEnabled = true; - UpdateIoFatWorkspaceModeState(); - } - - private void UpdateIoFatWorkspaceModeState() - { - if (_workspaceFatButton == null) - return; - - var loaded = _loadedIoFatWindow is { IsLoaded: true }; - _workspaceFatButton.Content = loaded ? "IO LIST FAT · LOADED" : "IO LIST FAT"; - _workspaceFatButton.ToolTip = loaded - ? "Return instantly to the loaded IO List FAT workspace" - : "Open an IO List FAT workbook or portable ARSAS project"; - } - - private static SolidColorBrush WorkspaceBrush(string hex) - { - var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); - brush.Freeze(); - return brush; } } diff --git a/tests/ARSAS.Tests/FieldRegressionFixTests.cs b/tests/ARSAS.Tests/FieldRegressionFixTests.cs index f15e789ee..48812ef4e 100644 --- a/tests/ARSAS.Tests/FieldRegressionFixTests.cs +++ b/tests/ARSAS.Tests/FieldRegressionFixTests.cs @@ -1,4 +1,6 @@ using ArIED61850Tester; +using System.Windows.Controls; +using System.Windows.Media; namespace ARSAS.Tests; @@ -23,13 +25,32 @@ public void IedTimestamp_LiveDisplayRoundsToMilliseconds_WhileFullSourceRemainsA } [Fact] - public void CommandPanel_DarkHeaderForcesReadableWhiteCaptions() + public void CommandPanel_DarkHeaderKeepsTargetBadgeReadableAcrossRefreshes() { - var source = File.ReadAllText(FindRepoFile("MainWindow.FieldPresentationFix.cs")); + RunInSta(() => + { + var title = new TextBlock { Text = "Command Dock" }; + var target = new TextBlock { Text = "TARGET · AA1EIF06R4" }; + var badge = new Border + { + Tag = "P0CommandTargetBadge", + Background = Brushes.Black, + BorderBrush = Brushes.Black, + Child = target + }; + var header = new StackPanel { Orientation = Orientation.Horizontal }; + header.Children.Add(title); + header.Children.Add(badge); + var expander = new Expander { Header = header }; - Assert.Contains("CommandPanelExpander", source, StringComparison.Ordinal); - Assert.Contains("expander.Foreground = Brushes.White", source, StringComparison.Ordinal); - Assert.Contains("text.Foreground = Brushes.White", source, StringComparison.Ordinal); + MainWindowFieldPresentationFix.ApplyDarkCommandHeaderContrast(expander); + MainWindowFieldPresentationFix.ApplyDarkCommandHeaderContrast(expander); + + Assert.Equal(Colors.White, Assert.IsType(title.Foreground).Color); + Assert.Equal(Color.FromRgb(0x58, 0x6B, 0x82), Assert.IsType(target.Foreground).Color); + Assert.Equal(Color.FromRgb(0xF4, 0xF7, 0xFB), Assert.IsType(badge.Background).Color); + Assert.Equal(Color.FromRgb(0xD6, 0xE0, 0xEC), Assert.IsType(badge.BorderBrush).Color); + }); } [Fact] @@ -81,4 +102,25 @@ private static string FindRepoFile(string relativePath) throw new FileNotFoundException( $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); } + + private static void RunInSta(Action action) + { + Exception? failure = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + failure = ex; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(8)), "WPF command-header regression timed out."); + Assert.Null(failure); + } } diff --git a/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs b/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs index 4a6a50719..8cc4c1359 100644 --- a/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs @@ -3,20 +3,23 @@ namespace ARSAS.Tests; public sealed class IoFatSclAppendWorkflowRegressionTests { [Fact] - public void P04_LoadedFatRoutesSclToAppendInsteadOfWorkspaceReplacement() + public void P04_LegacyWorkspaceOwnsExplicitSclAppendWithoutAHeaderSwitcherEntryPoint() { - var modeSwitch = ReadRepoFile("MainWindow.WorkspaceModeSwitch.cs"); + var lifecycle = ReadRepoFile("MainWindow.WorkspaceModeSwitch.cs"); + var addIed = ReadRepoFile("IoListTestingWindow.AddIed.cs"); + var append = ReadRepoFile("MainWindow.IoTesting.SclAppend.cs"); + var host = ReadRepoFile("MainWindow.IoTesting.cs"); - Assert.Contains("Add SCL / CID to loaded FAT workspace", modeSwitch, StringComparison.Ordinal); - Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", modeSwitch, StringComparison.Ordinal); - Assert.DoesNotContain( - "QueueIoFatWorkspaceReplacement(\n () => OpenSclFatTesting_Click", - modeSwitch, - StringComparison.Ordinal); + Assert.DoesNotContain("InstallWorkspaceModeSwitch", lifecycle, StringComparison.Ordinal); + Assert.DoesNotContain("OpenIoFatWorkspaceMenu", lifecycle, StringComparison.Ordinal); + Assert.Contains("ImportAdditionalSclSourcesAsync(engineeringWindow, dialog.FileNames)", addIed, StringComparison.Ordinal); + Assert.Contains("AppendSclIedsToLoadedFatAsync(this, sclPaths)", addIed, StringComparison.Ordinal); + Assert.Contains("Title = \"Add IEC 61850 SCL to loaded FAT workspace\"", append, StringComparison.Ordinal); + Assert.Contains("ImportAdditionalSclSourcesAsync(this, dialog.FileNames)", append, StringComparison.Ordinal); // Workbook and portable project opens intentionally retain replacement semantics. - Assert.Contains("() => OpenIoListTesting_Click(this, new RoutedEventArgs())", modeSwitch, StringComparison.Ordinal); - Assert.Contains("() => OpenIoListPackage_Click(this, new RoutedEventArgs())", modeSwitch, StringComparison.Ordinal); + Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListTesting_Click(sender, e))", host, StringComparison.Ordinal); + Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListPackage_Click(sender, e))", host, StringComparison.Ordinal); } [Fact] diff --git a/tests/ARSAS.Tests/MainWindowTopBarLayoutRegressionTests.cs b/tests/ARSAS.Tests/MainWindowTopBarLayoutRegressionTests.cs index 7a2a4aca7..ad7d1a80c 100644 --- a/tests/ARSAS.Tests/MainWindowTopBarLayoutRegressionTests.cs +++ b/tests/ARSAS.Tests/MainWindowTopBarLayoutRegressionTests.cs @@ -63,14 +63,14 @@ public void ResponsiveLabels_DoNotReplaceDiagnosticsAlertContentTree() } [Fact] - public void CompactHeader_DoesNotRemoveWorkspaceFunctions() + public void CompactHeader_HasNoLegacyWorkspaceSwitcherLayoutPath() { var source = File.ReadAllText(FindRepoFile("MainWindow.NavigationLayoutFix.cs")); - Assert.Contains("engineeringText.Text = medium ? \"ENGINEERING\" : \"ENG\"", source, StringComparison.Ordinal); - Assert.Contains("loaded ? \"FAT · LOADED\" : \"FAT\"", source, StringComparison.Ordinal); - Assert.Contains("WorkspaceModeChild_SizeChanged", source, StringComparison.Ordinal); - Assert.DoesNotContain("modeShell.Visibility = Visibility.Collapsed", source, StringComparison.Ordinal); + Assert.DoesNotContain("ApplyWorkspaceSwitchDensity", source, StringComparison.Ordinal); + Assert.DoesNotContain("WorkspaceModeChild_SizeChanged", source, StringComparison.Ordinal); + Assert.DoesNotContain("ARSAS_WORKSPACE_MODE_SWITCH", source, StringComparison.Ordinal); + Assert.Contains("NavNativeFatButton", source, StringComparison.Ordinal); } private static string FindRepoFile(string relativePath) diff --git a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs index 8f3bbcfe9..49a28b1f7 100644 --- a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs @@ -30,6 +30,11 @@ public void P4C_FatExposesExactNineColumnExplorerEvidenceContract() Assert.True(result > timestamp2); Assert.Contains("_nativeFatCanonicalGrid.Columns.Clear();", source, StringComparison.Ordinal); + Assert.Contains("AddCanonicalTextColumn(\"IEC Telegram\", nameof(Iec61850MonitorPoint.IecTelegram), 370);", source, StringComparison.Ordinal); + Assert.Contains("AddCanonicalTemplateColumn(\"Live Value\", \"ProcessValueBadgeTemplate\", 150);", source, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"Value 1\", NativeFatEvidenceField.Value1, 155, ReadNativeFatEvidence)", source, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"Value 2\", NativeFatEvidenceField.Value2, 155, ReadNativeFatEvidence)", source, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"Result\", NativeFatEvidenceField.Result, 68, ReadNativeFatEvidence)", source, StringComparison.Ordinal); Assert.Contains("ApplyNativeFatP4CColumnContract();", gridSource, StringComparison.Ordinal); Assert.DoesNotContain("ApplyNativeFatP4CColumnContract();", tabSource, StringComparison.Ordinal); Assert.Contains("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", gridSource, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs index 811736e28..dbb7a33bc 100644 --- a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs @@ -56,7 +56,7 @@ public void P1C_NativeFatUsesEngineeringGridStyleTemplateAndVirtualizationContra Assert.Contains("FindResource(\"ModernDataGrid\") as Style", gridSource, StringComparison.Ordinal); Assert.Contains("ApplyNativeFatP4CColumnContract();", gridSource, StringComparison.Ordinal); - Assert.Contains("AddCanonicalTemplateColumn(\"Live Value\", \"ProcessValueBadgeTemplate\", 125);", columnContract, StringComparison.Ordinal); + Assert.Contains("AddCanonicalTemplateColumn(\"Live Value\", \"ProcessValueBadgeTemplate\", 150);", columnContract, StringComparison.Ordinal); Assert.Contains("new NativeFatEvidenceBindingColumn(\"V1 Timestamp\", NativeFatEvidenceField.Value1Timestamp, 185, ReadNativeFatEvidence)", columnContract, StringComparison.Ordinal); Assert.Contains("new NativeFatEvidenceBindingColumn(\"V2 Timestamp\", NativeFatEvidenceField.Value2Timestamp, 185, ReadNativeFatEvidence)", columnContract, StringComparison.Ordinal); Assert.Contains("VirtualizingPanel.SetVirtualizationMode(_nativeFatCanonicalGrid, VirtualizationMode.Recycling);", gridSource, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs index f61a16422..fd76fc1a6 100644 --- a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs +++ b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs @@ -3,26 +3,21 @@ namespace ARSAS.Tests; public sealed class WorkspaceModeSwitchTests { [Fact] - public void MainWindow_AlwaysExposesEngineeringAndPersistentIoFatWorkspaceModes() + public void MainWindow_DoesNotInstallTheObsoleteEngineeringIoFatSwitcher() { var source = File.ReadAllText(FindRepoFile("MainWindow.WorkspaceModeSwitch.cs")); - Assert.Contains("ENGINEERING", source, StringComparison.Ordinal); - Assert.Contains("IO LIST FAT", source, StringComparison.Ordinal); - Assert.Contains("IO LIST FAT · LOADED", source, StringComparison.Ordinal); + Assert.DoesNotContain("WorkspaceModeSwitchTag", source, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterWorkspaceModeSwitch", source, StringComparison.Ordinal); + Assert.DoesNotContain("InstallWorkspaceModeSwitch", source, StringComparison.Ordinal); + Assert.DoesNotContain("_workspaceFatButton", source, StringComparison.Ordinal); + Assert.DoesNotContain("OpenOrResumeIoFatWorkspace_Click", source, StringComparison.Ordinal); + Assert.DoesNotContain("OpenIoFatWorkspaceMenu", source, StringComparison.Ordinal); Assert.Contains("_loadedIoFatWindow", source, StringComparison.Ordinal); - Assert.Contains("ShowLoadedIoFatWorkspace", source, StringComparison.Ordinal); - Assert.Contains("CurrentEngineeringSclSourcePaths", source, StringComparison.Ordinal); - Assert.Contains("OpenSclFatSourcesAsync(sharedSources, selectionMode: null)", source, StringComparison.Ordinal); - Assert.Contains("Continue loaded FAT project", source, StringComparison.Ordinal); - Assert.Contains("Import SCL / CID files", source, StringComparison.Ordinal); - Assert.Contains("Add SCL / CID to loaded FAT workspace", source, StringComparison.Ordinal); - Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", source, StringComparison.Ordinal); - Assert.Contains("OpenSclFatTesting_Click", source, StringComparison.Ordinal); - Assert.Contains("Import another IO List Excel workbook", source, StringComparison.Ordinal); - Assert.Contains("Open another portable .arsas project", source, StringComparison.Ordinal); + Assert.Contains("RegisterLoadedIoFatWindow", source, StringComparison.Ordinal); + Assert.Contains("ShowEngineeringWorkspaceFromFat", source, StringComparison.Ordinal); Assert.Contains("QueueIoFatWorkspaceReplacement", source, StringComparison.Ordinal); - Assert.Contains("FrameworkElement.LoadedEvent", source, StringComparison.Ordinal); + Assert.Contains("LoadedIoFatWindow_Closed", source, StringComparison.Ordinal); } [Fact] @@ -98,12 +93,8 @@ public void LoadingAnotherFatProject_IsExplicitWhileLoadedSclImportIsAdditive() Assert.Contains("loaded.Close();", source, StringComparison.Ordinal); Assert.Contains("Dispatcher.BeginInvoke(openReplacement", source, StringComparison.Ordinal); - // P0.4 keeps SCL additive while workbook/project opens remain explicit replacement. - Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", source, StringComparison.Ordinal); - Assert.DoesNotContain( - "QueueIoFatWorkspaceReplacement(\n () => OpenSclFatTesting_Click", - source, - StringComparison.Ordinal); + // Workbook/project opens remain explicit replacement. SCL import is owned by the + // canonical MainWindow workflow now that the obsolete header switcher is gone. Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListTesting_Click", hostSource, StringComparison.Ordinal); Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListPackage_Click", hostSource, StringComparison.Ordinal); } From e165ad2273fdd5ce2c1760668c731b683dd93a5c Mon Sep 17 00:00:00 2001 From: masarray Date: Sun, 13 Sep 2026 18:53:26 +0700 Subject: [PATCH 153/158] feat(fat): include verified auxiliary evidence in reports --- MainWindow.NativeFatDiagnostics.cs | 20 +- MainWindow.NativeFatPrintPreview.cs | 6 +- .../NativeFatAuxiliaryEvidenceCache.cs | 344 ++++++++++++++++++ .../NativeFatAuxiliaryReportDecorator.cs | 321 ++++++++++++++++ .../IoTesting/NativeFatP4DReportAdapter.cs | 9 +- .../NativeFatPrintPreviewSnapshot.cs | 11 +- .../IoTesting/NativeFatReportFinalization.cs | 3 +- .../NativeFatPatchBAuxiliaryEvidenceTests.cs | 300 +++++++++++++++ 8 files changed, 1002 insertions(+), 12 deletions(-) create mode 100644 Services/IoTesting/NativeFatAuxiliaryEvidenceCache.cs create mode 100644 Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs create mode 100644 tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs diff --git a/MainWindow.NativeFatDiagnostics.cs b/MainWindow.NativeFatDiagnostics.cs index 29295ff5e..f5e5960e5 100644 --- a/MainWindow.NativeFatDiagnostics.cs +++ b/MainWindow.NativeFatDiagnostics.cs @@ -16,6 +16,7 @@ public partial class MainWindow private CancellationTokenSource? _nativeFatComtradeDiscoveryCts; private long _nativeFatComtradeDiscoveryGeneration; private DispatcherTimer? _nativeFatDiagnosticRefreshTimer; + private readonly NativeFatAuxiliaryEvidenceCache _nativeFatAuxiliaryEvidenceCache = new(); private void InstallNativeFatDiagnosticButtons() { @@ -82,6 +83,7 @@ private void BindNativeFatDiagnostics(Iec61850MonitorDevice? device) if (!device.IsConnected || string.IsNullOrWhiteSpace(device.IpAddress)) { + _nativeFatAuxiliaryEvidenceCache.ClearComtrade(device); _nativeFatComtradeButton.Content = "COMTRADE —"; _nativeFatComtradeButton.IsEnabled = false; _nativeFatComtradeButton.ToolTip = "FileDirectory evidence is unavailable while the selected IED is disconnected."; @@ -119,6 +121,12 @@ private async Task DiscoverNativeFatComtradeAsync( var catalog = await client.DiscoverAsync(remoteDirectory: null, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); + await _nativeFatAuxiliaryEvidenceCache.RecordComtradeDiscoveryAsync( + device, + catalog.Records, + DateTimeOffset.UtcNow, + cancellationToken); + if (generation != _nativeFatComtradeDiscoveryGeneration || !string.Equals(SelectedDevice?.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase) || _nativeFatComtradeButton == null) @@ -140,6 +148,8 @@ private async Task DiscoverNativeFatComtradeAsync( if (generation != _nativeFatComtradeDiscoveryGeneration || _nativeFatComtradeButton == null) return; + _nativeFatAuxiliaryEvidenceCache.ClearComtrade(device); + _nativeFatComtradeButton.Content = "COMTRADE —"; _nativeFatComtradeButton.ToolTip = $"IEC 61850 FileDirectory could not be verified: {ex.Message}\nNo COMTRADE count is fabricated."; @@ -174,7 +184,9 @@ private void NativeFatTimeSyncButton_Click(object sender, RoutedEventArgs e) if (device == null) return; - var diagnostic = NativeFatTimeSyncDiagnosticService.Evaluate(device, DateTimeOffset.UtcNow); + var evaluatedAt = DateTimeOffset.UtcNow; + var diagnostic = NativeFatTimeSyncDiagnosticService.Evaluate(device, evaluatedAt); + _nativeFatAuxiliaryEvidenceCache.RecordTimeSyncEvaluation(device, diagnostic, evaluatedAt); var text = BuildNativeFatTimeSyncDiagnosticText(device, diagnostic); var body = new TextBox { @@ -280,13 +292,17 @@ private void RefreshNativeFatTimeSyncButton(Iec61850MonitorDevice? device) if (device == null || device.Points.Count == 0) { + if (device != null) + _nativeFatAuxiliaryEvidenceCache.ClearTimeSync(device); _nativeFatTimeSyncButton.Content = "Time Sync Review"; _nativeFatTimeSyncButton.IsEnabled = false; _nativeFatTimeSyncButton.ToolTip = "No canonical live rows are available for device-side time evidence."; return; } - var diagnostic = NativeFatTimeSyncDiagnosticService.Evaluate(device, DateTimeOffset.UtcNow); + var evaluatedAt = DateTimeOffset.UtcNow; + var diagnostic = NativeFatTimeSyncDiagnosticService.Evaluate(device, evaluatedAt); + _nativeFatAuxiliaryEvidenceCache.RecordTimeSyncEvaluation(device, diagnostic, evaluatedAt); _nativeFatTimeSyncButton.IsEnabled = true; _nativeFatTimeSyncButton.Content = diagnostic.IsSynchronized ? "Time Sync OK" diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs index 7f9df2e39..5dd71e71b 100644 --- a/MainWindow.NativeFatPrintPreview.cs +++ b/MainWindow.NativeFatPrintPreview.cs @@ -40,7 +40,8 @@ private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) CommitNativeFatEvidenceEdits(); var snapshot = NativeFatPrintPreviewSnapshot.Capture( device, - GetNativeFatSession(device.DeviceId)); + GetNativeFatSession(device.DeviceId), + _nativeFatAuxiliaryEvidenceCache.Capture(device)); ShowNativeFatPrintPreview(snapshot); SetStatus( $"FAT · Print Preview captured {snapshot.Rows.Count} immutable canonical row(s) for {snapshot.IedName}"); @@ -209,7 +210,8 @@ Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) CommitNativeFatEvidenceEdits(); currentSnapshot = NativeFatPrintPreviewSnapshot.Capture( device, - GetNativeFatSession(device.DeviceId)); + GetNativeFatSession(device.DeviceId), + _nativeFatAuxiliaryEvidenceCache.Capture(device)); RenderCurrentLayout(); SetStatus($"FAT · Print Preview refreshed from {currentSnapshot.IedName} evidence"); })); diff --git a/Services/IoTesting/NativeFatAuxiliaryEvidenceCache.cs b/Services/IoTesting/NativeFatAuxiliaryEvidenceCache.cs new file mode 100644 index 000000000..fba58c5e8 --- /dev/null +++ b/Services/IoTesting/NativeFatAuxiliaryEvidenceCache.cs @@ -0,0 +1,344 @@ +using System.Collections.ObjectModel; +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatComtradeRecordEvidence( + string RecordName, + DateTimeOffset? RecordDateUtc, + long KnownSizeBytes, + bool HasUnknownSize, + string Result); + +public sealed record NativeFatTimeSyncReportPoint( + string Role, + string SignalName, + string IecReference, + string Value, + string Quality, + string DeviceTimestamp, + double? DeltaSeconds, + string Result); + +public sealed class NativeFatTimeSyncReportEvidence +{ + private readonly ReadOnlyCollection _supportingPoints; + + internal NativeFatTimeSyncReportEvidence( + DateTimeOffset verifiedAtUtc, + string verdict, + string summary, + bool ltmsPresent, + int freshPrimaryTimestampCount, + IEnumerable supportingPoints) + { + VerifiedAtUtc = verifiedAtUtc.ToUniversalTime(); + Verdict = Copy(verdict); + Summary = Copy(summary); + LtmsPresent = ltmsPresent; + FreshPrimaryTimestampCount = freshPrimaryTimestampCount; + _supportingPoints = Array.AsReadOnly(supportingPoints.ToArray()); + } + + public DateTimeOffset VerifiedAtUtc { get; } + public string Verdict { get; } + public string Summary { get; } + public bool LtmsPresent { get; } + public int FreshPrimaryTimestampCount { get; } + public IReadOnlyList SupportingPoints => _supportingPoints; + public bool IsSynchronized => Verdict.Equals("OK", StringComparison.OrdinalIgnoreCase); + + internal NativeFatTimeSyncReportEvidence Copy() + => new( + VerifiedAtUtc, + Verdict, + Summary, + LtmsPresent, + FreshPrimaryTimestampCount, + _supportingPoints); + + private static string Copy(string? value) + => value?.Trim() ?? string.Empty; +} + +public sealed class NativeFatAuxiliaryEvidenceSnapshot +{ + private readonly ReadOnlyCollection _comtradeRecords; + + internal NativeFatAuxiliaryEvidenceSnapshot( + DateTimeOffset? comtradeVerifiedAtUtc, + IEnumerable comtradeRecords, + NativeFatTimeSyncReportEvidence? timeSync) + { + ComtradeVerifiedAtUtc = comtradeVerifiedAtUtc?.ToUniversalTime(); + _comtradeRecords = Array.AsReadOnly(comtradeRecords.ToArray()); + TimeSync = timeSync?.Copy(); + } + + public static NativeFatAuxiliaryEvidenceSnapshot Empty { get; } = + new(null, Array.Empty(), null); + + public DateTimeOffset? ComtradeVerifiedAtUtc { get; } + public IReadOnlyList ComtradeRecords => _comtradeRecords; + public NativeFatTimeSyncReportEvidence? TimeSync { get; } + + internal NativeFatAuxiliaryEvidenceSnapshot Copy() + => new(ComtradeVerifiedAtUtc, _comtradeRecords, TimeSync); +} + +/// +/// Thread-safe, in-memory, per-IED cache for evidence already obtained by the native FAT +/// diagnostics. Report capture only copies this state; it never reconnects, discovers files, +/// or starts acquisition. A completed empty/failed evaluation clears that evidence type so +/// report inclusion always fails closed. +/// +internal sealed class NativeFatAuxiliaryEvidenceCache +{ + private readonly object _gate = new(); + private readonly Dictionary _stateByIed = new(StringComparer.OrdinalIgnoreCase); + + public void RecordComtradeDiscovery( + Iec61850MonitorDevice device, + IEnumerable? records, + DateTimeOffset verifiedAtUtc) + { + ArgumentNullException.ThrowIfNull(device); + var projected = ProjectComtrade(records, CancellationToken.None); + Update(device, state => state with + { + ComtradeVerifiedAtUtc = verifiedAtUtc.ToUniversalTime(), + ComtradeRecords = projected + }); + } + + public async Task RecordComtradeDiscoveryAsync( + Iec61850MonitorDevice device, + IEnumerable? records, + DateTimeOffset verifiedAtUtc, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(device); + var key = ResolveStableIedKey(device); + var projected = await Task.Run( + () => ProjectComtrade(records, cancellationToken), + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + Update(key, state => state with + { + ComtradeVerifiedAtUtc = verifiedAtUtc.ToUniversalTime(), + ComtradeRecords = projected + }); + } + + public void ClearComtrade(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + Update(device, state => state with + { + ComtradeVerifiedAtUtc = null, + ComtradeRecords = Array.Empty() + }); + } + + public void RecordTimeSyncEvaluation( + Iec61850MonitorDevice device, + NativeFatTimeSyncDiagnosticResult diagnostic, + DateTimeOffset evaluatedAtUtc) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(diagnostic); + + var evidence = diagnostic.IsSynchronized + ? ProjectTimeSync(diagnostic, evaluatedAtUtc) + : null; + Update(device, state => state with { TimeSync = evidence }); + } + + public void ClearTimeSync(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + Update(device, state => state with { TimeSync = null }); + } + + public NativeFatAuxiliaryEvidenceSnapshot Capture(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + var key = ResolveStableIedKey(device); + lock (_gate) + { + if (!_stateByIed.TryGetValue(key, out var state)) + return NativeFatAuxiliaryEvidenceSnapshot.Empty; + + return new NativeFatAuxiliaryEvidenceSnapshot( + state.ComtradeVerifiedAtUtc, + state.ComtradeRecords, + state.TimeSync); + } + } + + private void Update(Iec61850MonitorDevice device, Func update) + => Update(ResolveStableIedKey(device), update); + + private void Update(string key, Func update) + { + lock (_gate) + { + _stateByIed.TryGetValue(key, out var current); + _stateByIed[key] = update(current ?? EvidenceState.Empty); + } + } + + private static IReadOnlyList ProjectComtrade( + IEnumerable? records, + CancellationToken cancellationToken) + { + if (records == null) + return Array.Empty(); + + var projected = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var record in records) + { + cancellationToken.ThrowIfCancellationRequested(); + if (record?.Files == null) + continue; + + var validFiles = record.Files + .Where(file => file != null && + (!string.IsNullOrWhiteSpace(file.RemotePath) || !string.IsNullOrWhiteSpace(file.Name))) + .GroupBy( + file => !string.IsNullOrWhiteSpace(file.RemotePath) ? file.RemotePath.Trim() : file.Name.Trim(), + StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToArray(); + if (validFiles.Length == 0) + continue; + + var name = FirstNonEmpty( + record.BaseName, + record.RecordId, + validFiles[0].BaseName, + validFiles[0].Name); + if (name.Length == 0) + continue; + + var knownSize = record.KnownSizeBytes > 0 + ? record.KnownSizeBytes + : SumKnownSize(validFiles); + var date = record.LastModifiedUtc ?? validFiles + .Where(file => file.LastModifiedUtc.HasValue) + .Select(file => file.LastModifiedUtc) + .Max(); + var evidence = new NativeFatComtradeRecordEvidence( + name, + date?.ToUniversalTime(), + knownSize, + record.HasUnknownSize || validFiles.Any(file => !file.SizeBytes.HasValue), + "OK"); + + var recordKey = FirstNonEmpty(record.RecordId, $"{record.RemoteDirectory}/{name}"); + projected[recordKey] = evidence; + } + + return projected.Values + .OrderByDescending(record => record.RecordDateUtc) + .ThenBy(record => record.RecordName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static NativeFatTimeSyncReportEvidence ProjectTimeSync( + NativeFatTimeSyncDiagnosticResult diagnostic, + DateTimeOffset evaluatedAtUtc) + { + var supporting = new List(2); + if (diagnostic.LtmsPresent) + { + NativeFatTimeSyncPointEvidence? ltms = null; + NativeFatTimeSyncPointEvidence? timestamp = null; + foreach (var point in diagnostic.PrimaryEvidence) + { + if (!point.Trusted) + continue; + if (point.Role.Equals("LTMS", StringComparison.OrdinalIgnoreCase)) + ltms ??= point; + else + timestamp ??= point; + if (ltms != null && timestamp != null) + break; + } + if (ltms != null) supporting.Add(ltms); + if (timestamp != null) supporting.Add(timestamp); + } + else + { + foreach (var point in diagnostic.PrimaryEvidence) + { + if (point.Trusted) + supporting.Add(point); + if (supporting.Count == 2) + break; + } + } + + return new NativeFatTimeSyncReportEvidence( + evaluatedAtUtc, + diagnostic.Verdict, + diagnostic.Summary, + diagnostic.LtmsPresent, + diagnostic.FreshPrimaryTimestampCount, + supporting.Select(point => new NativeFatTimeSyncReportPoint( + Copy(point.Role), + Copy(point.SignalName), + Copy(point.IecReference), + Copy(point.Value), + Copy(point.Quality), + Copy(point.DeviceTimestamp), + point.DeltaSeconds, + "OK"))); + } + + private static long SumKnownSize(IEnumerable files) + { + long total = 0; + foreach (var file in files) + { + var size = (long)(file.SizeBytes ?? 0u); + total = long.MaxValue - total < size ? long.MaxValue : total + size; + } + return total; + } + + private static string ResolveStableIedKey(Iec61850MonitorDevice device) + { + var namedIdentity = new[] { device.SclIedName, device.Name } + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && !IsPlaceholderIdentity(value.Trim())) + ?.Trim() ?? string.Empty; + if (namedIdentity.Length > 0) + return $"ied:{namedIdentity}"; + + if (!string.IsNullOrWhiteSpace(device.IpAddress)) + return $"endpoint:{device.IpAddress.Trim()}:{device.Port}"; + + return $"device:{device.DeviceId.Trim()}"; + } + + private static bool IsPlaceholderIdentity(string value) + => value.Equals("IED", StringComparison.OrdinalIgnoreCase) || + value.Equals("New IED", StringComparison.OrdinalIgnoreCase) || + value.Equals("Device", StringComparison.OrdinalIgnoreCase); + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + + private static string Copy(string? value) + => value?.Trim() ?? string.Empty; + + private sealed record EvidenceState( + DateTimeOffset? ComtradeVerifiedAtUtc, + IReadOnlyList ComtradeRecords, + NativeFatTimeSyncReportEvidence? TimeSync) + { + public static EvidenceState Empty { get; } = + new(null, Array.Empty(), null); + } +} diff --git a/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs b/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs new file mode 100644 index 000000000..0c8d66551 --- /dev/null +++ b/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs @@ -0,0 +1,321 @@ +using System.Globalization; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Appends only successfully verified auxiliary evidence already present in the immutable +/// native FAT snapshot. It performs no device access and leaves final page-total correction +/// to . +/// +internal static class NativeFatAuxiliaryReportDecorator +{ + private const double PageWidth = 842d; + private const double PageHeight = 595d; + private const double Margin = 30d; + private const double ContentWidth = PageWidth - (Margin * 2d); + private const int ComtradeRowsPerPage = 8; + + private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); + private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); + private static readonly IoFatReportColor SoftBlue = IoFatReportColor.FromHex("EFF6FF"); + private static readonly IoFatReportColor Border = IoFatReportColor.FromHex("D9E4F0"); + private static readonly IoFatReportColor Muted = IoFatReportColor.FromHex("64748B"); + private static readonly IoFatReportColor Ink = IoFatReportColor.FromHex("1F2937"); + private static readonly IoFatReportColor White = IoFatReportColor.FromHex("FFFFFF"); + private static readonly IoFatReportColor Pass = IoFatReportColor.FromHex("15803D"); + private static readonly IoFatReportColor SoftPass = IoFatReportColor.FromHex("F0FDF4"); + + public static IoFatReportLayoutPlan AppendSuccessfulEvidence( + IoFatReportLayoutPlan baseLayout, + NativeFatPrintPreviewSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(baseLayout); + ArgumentNullException.ThrowIfNull(snapshot); + + var pages = baseLayout.Pages.ToList(); + var auxiliary = snapshot.AuxiliaryEvidence; + + if (auxiliary.ComtradeVerifiedAtUtc.HasValue && auxiliary.ComtradeRecords.Count > 0) + { + foreach (var chunk in auxiliary.ComtradeRecords.Chunk(ComtradeRowsPerPage)) + { + var pageNumber = pages.Count + 1; + pages.Add(BuildComtradePage( + snapshot, + auxiliary.ComtradeVerifiedAtUtc.Value, + chunk, + pageNumber, + continued: pageNumber > baseLayout.Pages.Count + 1, + baseLayout.CreatedAt)); + } + } + + if (auxiliary.TimeSync is { IsSynchronized: true } timeSync) + { + var pageNumber = pages.Count + 1; + pages.Add(BuildTimeSyncPage(snapshot, timeSync, pageNumber, baseLayout.CreatedAt)); + } + + return pages.Count == baseLayout.Pages.Count + ? baseLayout + : new IoFatReportLayoutPlan(baseLayout.ProjectId, baseLayout.CreatedAt, baseLayout.Draft, pages); + } + + private static IoFatReportPagePlan BuildComtradePage( + NativeFatPrintPreviewSnapshot snapshot, + DateTimeOffset verifiedAtUtc, + IReadOnlyList records, + int pageNumber, + bool continued, + DateTimeOffset createdAt) + { + var commands = new List(); + AddHeader( + commands, + "IEC 61850 Fault Record (COMTRADE)", + continued ? "Available Fault Records · continued" : "Available Fault Records"); + AddScopeCard( + commands, + snapshot, + $"FileDirectory verified · {verifiedAtUtc.ToUniversalTime():yyyy-MM-dd HH:mm:ss 'UTC'}", + $"{snapshot.AuxiliaryEvidence.ComtradeRecords.Count:N0} record(s)"); + + var widths = new[] { 330d, 190d, 160d, 102d }; + var headers = new[] { "Record Name", "Record Date", "Size", "Result" }; + var y = 438d; + DrawTableHeader(commands, widths, headers, y); + y -= 28d; + + foreach (var record in records) + { + const double rowHeight = 42d; + var values = new[] + { + Fit(record.RecordName, 66), + record.RecordDateUtc?.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "—", + FormatSize(record.KnownSizeBytes, record.HasUnknownSize), + "OK" + }; + DrawRow(commands, widths, values, y, rowHeight, resultColumn: 3); + y -= rowHeight; + } + + AddFooter(commands, pageNumber, createdAt, "Verified IEC 61850 FileDirectory evidence."); + return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); + } + + private static IoFatReportPagePlan BuildTimeSyncPage( + NativeFatPrintPreviewSnapshot snapshot, + NativeFatTimeSyncReportEvidence evidence, + int pageNumber, + DateTimeOffset createdAt) + { + var commands = new List(); + AddHeader(commands, "IEC 61850 Time Synchronization Evidence", "Verified device-side time evidence"); + AddScopeCard( + commands, + snapshot, + $"Evaluated · {evidence.VerifiedAtUtc.ToUniversalTime():yyyy-MM-dd HH:mm:ss 'UTC'}", + "Time Sync OK"); + + Rect(commands, Margin, 438d, ContentWidth, 68d, 4d, SoftPass, Border, 0.65d); + Text(commands, Margin + 12d, 417d, 110d, "RESULT", IoFatReportFontKind.Bold, 6.1d, Muted); + Text(commands, Margin + 12d, 394d, 110d, "OK", IoFatReportFontKind.Bold, 13.2d, Pass); + Text(commands, Margin + 126d, 417d, ContentWidth - 138d, "VERIFICATION BASIS", IoFatReportFontKind.Bold, 6.1d, Muted); + var summaryLines = Wrap(evidence.Summary, 104, 2); + for (var index = 0; index < summaryLines.Count; index++) + Text(commands, Margin + 126d, 399d - (index * 12d), ContentWidth - 138d, summaryLines[index], IoFatReportFontKind.Regular, 7.0d, Ink); + + Text( + commands, + Margin, + 350d, + ContentWidth, + evidence.LtmsPresent + ? $"LTMS verified · {evidence.FreshPrimaryTimestampCount:N0} fresh independent IEC timestamp(s)" + : $"LTMS not exposed · {evidence.FreshPrimaryTimestampCount:N0} fresh independent IEC timestamps verified", + IoFatReportFontKind.Bold, + 7.1d, + Navy); + + var widths = new[] { 82d, 226d, 92d, 68d, 158d, 88d, 68d }; + var headers = new[] { "Evidence", "IEC Reference", "Value", "Quality", "IED Timestamp", "Delta", "Result" }; + var y = 330d; + DrawTableHeader(commands, widths, headers, y); + y -= 28d; + + foreach (var point in evidence.SupportingPoints) + { + const double rowHeight = 48d; + var values = new[] + { + Fit(point.Role, 14), + Fit(FirstNonEmpty(point.IecReference, point.SignalName), 42), + Fit(point.Value, 16), + Fit(point.Quality, 12), + Fit(point.DeviceTimestamp, 28), + point.DeltaSeconds.HasValue + ? $"{point.DeltaSeconds.Value:0.000} s" + : "—", + "OK" + }; + DrawRow(commands, widths, values, y, rowHeight, resultColumn: 6); + y -= rowHeight; + } + + AddFooter(commands, pageNumber, createdAt, "Read-only evaluator; SNTP activity alone does not grant OK."); + return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); + } + + private static void AddHeader(ICollection commands, string title, string subtitle) + { + NativeFatReportBranding.AddLogo(commands, PageWidth - Margin - 102d, 582d); + Text(commands, Margin, 566d, 490d, "IEC 61850 FAT", IoFatReportFontKind.Bold, 7.2d, Muted); + Text(commands, Margin, 544d, 570d, title, IoFatReportFontKind.Bold, 16.4d, Navy); + Text(commands, Margin, 522d, 560d, subtitle, IoFatReportFontKind.Regular, 8.0d, Muted); + Line(commands, Margin, 498d, PageWidth - Margin, 498d, Border, 0.8d); + } + + private static void AddScopeCard( + ICollection commands, + NativeFatPrintPreviewSnapshot snapshot, + string detail, + string result) + { + Rect(commands, Margin, 482d, ContentWidth, 34d, 3d, SoftBlue, Border, 0.6d); + Text(commands, Margin + 10d, 461d, 390d, + $"{Clean(snapshot.IedName)} · {Clean(snapshot.IpAddress)}:{snapshot.Port}", + IoFatReportFontKind.Bold, 7.5d, Ink); + Text(commands, Margin + 350d, 461d, 280d, detail, IoFatReportFontKind.Regular, 6.4d, Muted); + Text(commands, PageWidth - Margin - 118d, 461d, 108d, result, IoFatReportFontKind.Bold, 7.0d, Pass); + } + + private static void DrawTableHeader( + ICollection commands, + IReadOnlyList widths, + IReadOnlyList headers, + double y) + { + var x = Margin; + for (var index = 0; index < headers.Count; index++) + { + Rect(commands, x, y, widths[index], 28d, 0d, SoftBlue, Border, 0.45d); + Text(commands, x + 5d, y - 18d, widths[index] - 10d, headers[index], IoFatReportFontKind.Bold, 6.2d, Blue); + x += widths[index]; + } + } + + private static void DrawRow( + ICollection commands, + IReadOnlyList widths, + IReadOnlyList values, + double y, + double height, + int resultColumn) + { + var x = Margin; + for (var index = 0; index < values.Count; index++) + { + Rect(commands, x, y, widths[index], height, 0d, White, Border, 0.4d); + Text( + commands, + x + 5d, + y - (height / 2d) - 2d, + widths[index] - 10d, + values[index], + index == resultColumn ? IoFatReportFontKind.Bold : index is 1 or 4 or 5 ? IoFatReportFontKind.Mono : IoFatReportFontKind.Regular, + index is 1 or 4 or 5 ? 5.6d : 6.4d, + index == resultColumn ? Pass : Ink); + x += widths[index]; + } + } + + private static void AddFooter( + ICollection commands, + int pageNumber, + DateTimeOffset createdAt, + string note) + { + Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); + Text(commands, Margin, 24d, 620d, + $"FAT evidence captured · {createdAt:yyyy-MM-dd HH:mm:ss zzz} | {note}", + IoFatReportFontKind.Regular, 6.2d, Muted); + Text(commands, PageWidth - Margin - 118d, 24d, 118d, + $"Page {pageNumber} / {pageNumber}", + IoFatReportFontKind.Regular, 6.2d, Muted); + } + + private static string FormatSize(long knownSizeBytes, bool hasUnknownSize) + { + var prefix = hasUnknownSize ? ">= " : string.Empty; + var size = Math.Max(0L, knownSizeBytes); + if (size >= 1024L * 1024L * 1024L) + return $"{prefix}{size / (1024d * 1024d * 1024d):0.##} GB"; + if (size >= 1024L * 1024L) + return $"{prefix}{size / (1024d * 1024d):0.##} MB"; + if (size >= 1024L) + return $"{prefix}{size / 1024d:0.##} KB"; + return hasUnknownSize && size == 0 ? "Unknown" : $"{size:N0} B"; + } + + private static IReadOnlyList Wrap(string? value, int maxChars, int maxLines) + { + var remaining = Clean(value); + var lines = new List(); + while (remaining.Length > maxChars && lines.Count < maxLines - 1) + { + var split = remaining.LastIndexOf(' ', maxChars); + if (split < maxChars / 2) + split = maxChars; + lines.Add(remaining[..split].Trim()); + remaining = remaining[split..].Trim(); + } + lines.Add(Fit(remaining, maxChars)); + return lines; + } + + private static string Fit(string? value, int maxChars) + { + var text = Clean(value); + return text.Length <= maxChars ? text : text[..Math.Max(1, maxChars - 1)] + "…"; + } + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "—"; + + private static string Clean(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); + + private static void Rect( + ICollection commands, + double x, + double top, + double width, + double height, + double radius, + IoFatReportColor fill, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportRectCommand(x, top, width, height, radius, fill, stroke, strokeThickness)); + + private static void Line( + ICollection commands, + double x1, + double y1, + double x2, + double y2, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, strokeThickness)); + + private static void Text( + ICollection commands, + double x, + double baselineY, + double width, + string text, + IoFatReportFontKind font, + double fontSize, + IoFatReportColor color) + => commands.Add(new IoFatReportTextCommand(x, baselineY, width, text, font, fontSize, color)); +} diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs index 2b0bf27a9..4e0559ea6 100644 --- a/Services/IoTesting/NativeFatP4DReportAdapter.cs +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -97,9 +97,10 @@ public static IoFatReportLayoutPlan Build(NativeFatPrintPreviewSnapshot snapshot pages.Select((commands, index) => new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, commands.ToArray())).ToArray()); - // Final acceptance sign-off is part of the exact immutable layout shared by preview - // and Save PDF. Fields stay blank by design; no operator identity/evidence is invented. - return NativeFatReportFinalization.AppendSignOff(baseLayout, snapshot); + // Auxiliary pages consume only evidence already copied into the immutable snapshot. + // Final acceptance sign-off remains the last page shared by Preview and Save PDF. + var withAuxiliaryEvidence = NativeFatAuxiliaryReportDecorator.AppendSuccessfulEvidence(baseLayout, snapshot); + return NativeFatReportFinalization.AppendSignOff(withAuxiliaryEvidence, snapshot); } private static List NewPage( @@ -278,4 +279,4 @@ private static IoFatReportColor ResultColor(string? result) private static string Clean(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); -} \ No newline at end of file +} diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index 055efa9f9..9e435ea62 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -31,7 +31,8 @@ private NativeFatPrintPreviewSnapshot( string iedName, string ipAddress, int port, - IReadOnlyCollection rows) + IReadOnlyCollection rows, + NativeFatAuxiliaryEvidenceSnapshot auxiliaryEvidence) { CapturedAt = capturedAt; DeviceId = deviceId; @@ -39,6 +40,7 @@ private NativeFatPrintPreviewSnapshot( IpAddress = ipAddress; Port = port; _rows = Array.AsReadOnly(rows.ToArray()); + AuxiliaryEvidence = auxiliaryEvidence.Copy(); } public DateTimeOffset CapturedAt { get; } @@ -47,12 +49,14 @@ private NativeFatPrintPreviewSnapshot( public string IpAddress { get; } public int Port { get; } public IReadOnlyList Rows => _rows; + public NativeFatAuxiliaryEvidenceSnapshot AuxiliaryEvidence { get; } public int CompleteCount => _rows.Count(row => HasEvidence(row.Value1) && HasEvidence(row.Value2)); public string ProgressText => $"{CompleteCount}/{_rows.Count} complete"; public static NativeFatPrintPreviewSnapshot Capture( Iec61850MonitorDevice device, - NativeFatIedSessionCacheState cache) + NativeFatIedSessionCacheState cache, + NativeFatAuxiliaryEvidenceSnapshot? auxiliaryEvidence = null) { ArgumentNullException.ThrowIfNull(device); ArgumentNullException.ThrowIfNull(cache); @@ -87,7 +91,8 @@ public static NativeFatPrintPreviewSnapshot Capture( Copy(device.Name), Copy(device.IpAddress), device.Port, - rows); + rows, + auxiliaryEvidence ?? NativeFatAuxiliaryEvidenceSnapshot.Empty); } private static string DisplayTimestamp(FatValueEvidence? evidence) diff --git a/Services/IoTesting/NativeFatReportFinalization.cs b/Services/IoTesting/NativeFatReportFinalization.cs index 63aa28796..8f2fc4539 100644 --- a/Services/IoTesting/NativeFatReportFinalization.cs +++ b/Services/IoTesting/NativeFatReportFinalization.cs @@ -4,7 +4,8 @@ namespace ArIED61850Tester.Services.IoTesting; /// Finalizes the immutable native FAT layout without rebuilding an IoTestProject. /// The sign-off page is intentionally blank evidence: it provides the controlled /// TESTED BY / WITNESSED BY / APPROVED BY acceptance fields and never invents names, -/// signatures, dates, COMTRADE records, or time-sync evidence. +/// signatures or dates. Auxiliary evidence is appended from the immutable snapshot before +/// this finalization step; this class never invents COMTRADE or time-sync facts. /// internal static class NativeFatReportFinalization { diff --git a/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs b/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs new file mode 100644 index 000000000..8c2edf146 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs @@ -0,0 +1,300 @@ +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatPatchBAuxiliaryEvidenceTests +{ + [Fact] + public void ComtradeVerifiedRecords_AppearWithRequiredColumnsAndImmutableValues() + { + var device = Device("runtime-a", "AA1E1F06R4"); + var files = new List + { + new() + { + Name = "FAULT_001.cfg", + RemotePath = "/COMTRADE/FAULT_001.cfg", + BaseName = "FAULT_001", + SizeBytes = 512, + LastModifiedUtc = new DateTimeOffset(2026, 9, 10, 8, 30, 0, TimeSpan.Zero) + }, + new() + { + Name = "FAULT_001.dat", + RemotePath = "/COMTRADE/FAULT_001.dat", + BaseName = "FAULT_001", + SizeBytes = 1024, + LastModifiedUtc = new DateTimeOffset(2026, 9, 10, 8, 31, 0, TimeSpan.Zero) + } + }; + var records = new List + { + new() + { + RecordId = "/COMTRADE/FAULT_001", + BaseName = "FAULT_001", + KnownSizeBytes = 1536, + LastModifiedUtc = new DateTimeOffset(2026, 9, 10, 8, 31, 0, TimeSpan.Zero), + Files = files + } + }; + var cache = new NativeFatAuxiliaryEvidenceCache(); + cache.RecordComtradeDiscovery( + device, + records, + new DateTimeOffset(2026, 9, 10, 8, 32, 0, TimeSpan.Zero)); + + var auxiliary = cache.Capture(device); + records.Clear(); + files.Clear(); + var snapshot = NativeFatPrintPreviewSnapshot.Capture( + device, + new NativeFatIedSessionCacheState(), + auxiliary); + var layout = NativeFatP4DReportAdapter.Build(snapshot); + var text = ReportText(layout); + var pdf = IoFatPdfReportService.GenerateLayout(layout, snapshot.IedName, "COMTRADE"); + + Assert.Single(snapshot.AuxiliaryEvidence.ComtradeRecords); + Assert.Equal("FAULT_001", snapshot.AuxiliaryEvidence.ComtradeRecords[0].RecordName); + Assert.Contains("IEC 61850 Fault Record (COMTRADE)", text); + Assert.Contains("Available Fault Records", text); + Assert.Contains("Record Name", text); + Assert.Contains("Record Date", text); + Assert.Contains("Size", text); + Assert.Contains("Result", text); + Assert.Contains("FAULT_001", text); + Assert.Contains("2026-09-10 08:31:00 UTC", text); + Assert.Contains("1.5 KB", text); + Assert.Contains("OK", text); + Assert.Equal("%PDF-1.4", System.Text.Encoding.ASCII.GetString(pdf, 0, 8)); + for (var index = 0; index < layout.Pages.Count; index++) + { + var expected = $"Page {index + 1} / {layout.Pages.Count}"; + Assert.Contains( + layout.Pages[index].Commands.OfType(), + command => command.Text == expected); + } + } + + [Fact] + public void ComtradeFailedOrEmptyDiscovery_OmitsWholeSection() + { + var device = Device("runtime-a", "AA1E1F06R4"); + var cache = new NativeFatAuxiliaryEvidenceCache(); + + Assert.DoesNotContain( + "IEC 61850 Fault Record (COMTRADE)", + ReportText(Build(device, cache))); + + cache.RecordComtradeDiscovery( + device, + [new Iec61850FaultRecordSet { RecordId = "EMPTY", BaseName = "EMPTY" }], + DateTimeOffset.UtcNow); + Assert.DoesNotContain( + "IEC 61850 Fault Record (COMTRADE)", + ReportText(Build(device, cache))); + + cache.RecordComtradeDiscovery(device, [ValidRecord("FAULT_002")], DateTimeOffset.UtcNow); + cache.ClearComtrade(device); + Assert.DoesNotContain( + "IEC 61850 Fault Record (COMTRADE)", + ReportText(Build(device, cache))); + } + + [Fact] + public void TimeSyncOk_AppearsWithOnlyBoundedSupportingEvidence() + { + var device = Device("runtime-a", "AA1E1F06R4"); + var cache = new NativeFatAuxiliaryEvidenceCache(); + var diagnostic = Diagnostic( + true, + "OK", + "LTMS evidence is present and cross-checked by a fresh good-quality IEC timestamp.", + false); + + cache.RecordTimeSyncEvaluation( + device, + diagnostic, + new DateTimeOffset(2026, 9, 10, 8, 40, 0, TimeSpan.Zero)); + var snapshot = NativeFatPrintPreviewSnapshot.Capture( + device, + new NativeFatIedSessionCacheState(), + cache.Capture(device)); + var text = ReportText(NativeFatP4DReportAdapter.Build(snapshot)); + + Assert.NotNull(snapshot.AuxiliaryEvidence.TimeSync); + Assert.True(snapshot.AuxiliaryEvidence.TimeSync!.IsSynchronized); + Assert.Equal(2, snapshot.AuxiliaryEvidence.TimeSync.SupportingPoints.Count); + Assert.Contains("IEC 61850 Time Synchronization Evidence", text); + Assert.Contains("Time Sync OK", text); + Assert.Contains(text, value => value.Contains("LTMS verified", StringComparison.Ordinal)); + Assert.Contains("AA1E1F06R4LD0/LLN0.LTMS", text); + Assert.Contains("AA1E1F06R4LD0/XCBR1.Pos.stVal", text); + } + + [Theory] + [InlineData("REVIEW", false)] + [InlineData("NOT OK", true)] + public void TimeSyncReviewOrNotOk_OmitsWholeSection(string verdict, bool explicitNegative) + { + var device = Device("runtime-a", "AA1E1F06R4"); + var cache = new NativeFatAuxiliaryEvidenceCache(); + cache.RecordTimeSyncEvaluation(device, Diagnostic(true, "OK", "Verified.", false), DateTimeOffset.UtcNow); + cache.RecordTimeSyncEvaluation( + device, + Diagnostic(false, verdict, "Synchronization is not proven.", explicitNegative), + DateTimeOffset.UtcNow); + + var text = ReportText(Build(device, cache)); + Assert.DoesNotContain("IEC 61850 Time Synchronization Evidence", text); + Assert.DoesNotContain("Time Sync OK", text); + } + + [Fact] + public void Cache_IsScopedByStableIedNameAcrossRuntimeDeviceIds() + { + var first = Device("runtime-a", "DISPLAY-A"); + first.SclIedName = "AA1E1F06R4"; + var reopened = Device("runtime-b", "DISPLAY-B"); + reopened.SclIedName = "AA1E1F06R4"; + var other = Device("runtime-c", "AA1E1F06R5"); + var cache = new NativeFatAuxiliaryEvidenceCache(); + + cache.RecordComtradeDiscovery(first, [ValidRecord("FAULT_STABLE")], DateTimeOffset.UtcNow); + + Assert.Single(cache.Capture(reopened).ComtradeRecords); + Assert.Empty(cache.Capture(other).ComtradeRecords); + } + + [Fact] + public void PreviewAndSnapshotSources_DoNotStartDiscoveryReconnectAcquisitionOrLegacyFatWindow() + { + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var snapshot = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); + var decorator = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs")); + + Assert.Contains("_nativeFatAuxiliaryEvidenceCache.Capture(device)", preview, StringComparison.Ordinal); + foreach (var source in new[] { preview, snapshot, decorator }) + { + Assert.DoesNotContain("FaultRecordTransferClient", source, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartMonitoringAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("IoListTestingWindow", source, StringComparison.Ordinal); + } + } + + private static IoFatReportLayoutPlan Build( + Iec61850MonitorDevice device, + NativeFatAuxiliaryEvidenceCache cache) + => NativeFatP4DReportAdapter.Build(NativeFatPrintPreviewSnapshot.Capture( + device, + new NativeFatIedSessionCacheState(), + cache.Capture(device))); + + private static string[] ReportText(IoFatReportLayoutPlan layout) + => layout.Pages + .SelectMany(page => page.Commands) + .OfType() + .Select(command => command.Text) + .ToArray(); + + private static Iec61850MonitorDevice Device(string deviceId, string name) + => new() + { + DeviceId = deviceId, + Name = name, + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true + }; + + private static Iec61850FaultRecordSet ValidRecord(string name) + => new() + { + RecordId = $"/COMTRADE/{name}", + BaseName = name, + KnownSizeBytes = 2048, + LastModifiedUtc = DateTimeOffset.UtcNow, + Files = + [ + new Iec61850FaultRecordFile + { + Name = $"{name}.cff", + RemotePath = $"/COMTRADE/{name}.cff", + BaseName = name, + SizeBytes = 2048, + LastModifiedUtc = DateTimeOffset.UtcNow + } + ] + }; + + private static NativeFatTimeSyncDiagnosticResult Diagnostic( + bool synchronized, + string verdict, + string summary, + bool explicitNegative) + => new( + synchronized, + verdict, + summary, + true, + synchronized, + synchronized ? 1 : 0, + explicitNegative, + synchronized + ? + [ + new NativeFatTimeSyncPointEvidence( + "LTMS", + "LTMS", + "AA1E1F06R4LD0/LLN0.LTMS", + "2026-09-10T08:40:00Z", + "Good", + "2026-09-10T08:40:00Z", + 0.02, + true), + new NativeFatTimeSyncPointEvidence( + "IEC timestamp", + "Breaker", + "AA1E1F06R4LD0/XCBR1.Pos.stVal", + "Open [01]", + "Good", + "2026-09-10T08:40:00Z", + 0.03, + true), + new NativeFatTimeSyncPointEvidence( + "IEC timestamp", + "Extra", + "AA1E1F06R4LD0/GGIO1.Ind1.stVal", + "False", + "Good", + "2026-09-10T08:40:00Z", + 0.04, + true) + ] + : Array.Empty(), + Array.Empty()); + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + directory = directory.Parent; + } + + throw new DirectoryNotFoundException($"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} From 7cded3ec153ce644c2aa106e94c319e2979835bd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 19:38:25 +0700 Subject: [PATCH 154/158] FAT report: improve evidence table readability --- .../IoTesting/NativeFatP4DReportAdapter.cs | 72 +++++++++---------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs index 4e0559ea6..5cad8a9ae 100644 --- a/Services/IoTesting/NativeFatP4DReportAdapter.cs +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -13,16 +13,18 @@ internal static class NativeFatP4DReportAdapter private const double Margin = 30d; private const double ContentTop = 466d; private const double ContentBottom = 52d; - private const double HeaderHeight = 24d; - private const double MinimumRowHeight = 30d; - private const int TelegramCharsPerLine = 38; + private const double HeaderHeight = 25d; + private const double MinimumRowHeight = 32d; + private const double TelegramBaseFontSize = 6.2d; + private const double TelegramMinimumFontSize = 4.6d; - // Exact P4C visible contract. Total width = 782 pt (842 - 2 * 30 margin). - // Timestamp columns are intentionally first-class columns so the immutable report mirrors - // the FAT grid rather than collapsing observation metadata into Value 1 / Value 2 text. - private static readonly double[] Widths = [90d, 200d, 65d, 70d, 62d, 90d, 62d, 90d, 53d]; + // Customer-facing evidence table. Total width = 782 pt (842 - 2 * 30 margin). + // Live Value is intentionally omitted from the report: FAT evidence is Value 1 / Value 2. + // The reclaimed width is prioritized for IEC Telegram and evidence timestamps so the + // printable table stays readable while the IEC identity remains on one line. + private static readonly double[] Widths = [72d, 280d, 44d, 76d, 92d, 76d, 92d, 50d]; private static readonly string[] Headers = - ["Signal", "IEC Telegram", "Quality", "Live Value", "Value 1", "V1 Timestamp", "Value 2", "V2 Timestamp", "Result"]; + ["Signal", "IEC Telegram", "Quality", "Value 1", "V1 Timestamp", "Value 2", "V2 Timestamp", "Result"]; private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); @@ -167,11 +169,11 @@ private static void DrawTableHeader(List page, ref double y) page.Add(new IoFatReportRectCommand(x, y, Widths[index], HeaderHeight, 0d, SoftBlue, Border, 0.45d)); page.Add(new IoFatReportTextCommand( x + 4d, - y - 15.5d, + y - 16.5d, Widths[index] - 8d, Headers[index], IoFatReportFontKind.Bold, - index is 5 or 7 ? 5.25d : 5.8d, + index is 4 or 6 ? 5.8d : 6.4d, Blue)); x += Widths[index]; } @@ -179,7 +181,7 @@ private static void DrawTableHeader(List page, ref double y) } private static double GetRowHeight(NativeFatPrintPreviewRow row) - => Math.Max(MinimumRowHeight, 12d + (WrapTelegram(row.IecTelegram).Count * 8.6d)); + => MinimumRowHeight; private static void DrawRow( List page, @@ -193,7 +195,6 @@ private static void DrawRow( Clean(row.Signal), string.Empty, Clean(row.Quality), - Clean(row.LiveValue), Clean(row.Value1), Clean(row.Value1TimestampText), Clean(row.Value2), @@ -208,33 +209,28 @@ private static void DrawRow( if (index == 1) { - var lineY = y - 12d; - foreach (var line in WrapTelegram(row.IecTelegram)) - { - page.Add(new IoFatReportTextCommand( - x + 4d, - lineY, - Widths[index] - 8d, - line, - IoFatReportFontKind.Mono, - 5.35d, - Ink)); - lineY -= 8.6d; - } + page.Add(new IoFatReportTextCommand( + x + 4d, + y - 19d, + Widths[index] - 8d, + Clean(row.IecTelegram), + IoFatReportFontKind.Mono, + TelegramFontSize(row.IecTelegram), + Ink)); } else { - var isTimestamp = index is 5 or 7; + var isTimestamp = index is 4 or 6; page.Add(new IoFatReportTextCommand( x + 4d, - y - 18d, + y - 19d, Widths[index] - 8d, cells[index], isTimestamp ? IoFatReportFontKind.Mono - : index is 0 or 8 ? IoFatReportFontKind.Bold : IoFatReportFontKind.Regular, - isTimestamp ? 4.9d : 5.8d, - index == 8 ? ResultColor(reportResult) : Ink)); + : index is 0 or 7 ? IoFatReportFontKind.Bold : IoFatReportFontKind.Regular, + isTimestamp ? 5.6d : 6.5d, + index == 7 ? ResultColor(reportResult) : Ink)); } x += Widths[index]; @@ -243,18 +239,18 @@ private static void DrawRow( y -= height; } - private static IReadOnlyList WrapTelegram(string? value) + private static double TelegramFontSize(string? value) { var text = Clean(value); if (text.Length == 0) - return ["—"]; - if (text.Length <= TelegramCharsPerLine) - return [text]; + return TelegramBaseFontSize; - var lines = new List(); - for (var offset = 0; offset < text.Length; offset += TelegramCharsPerLine) - lines.Add(text.Substring(offset, Math.Min(TelegramCharsPerLine, text.Length - offset))); - return lines; + // Conservative monospace estimate: ~0.62 em per glyph. The normal case keeps the + // larger readable size; unusually long IEC references shrink only as much as needed + // to stay on one physical report row instead of wrapping or clipping. + var availableWidth = Widths[1] - 8d; + var fitted = availableWidth / (text.Length * 0.62d); + return Math.Clamp(fitted, TelegramMinimumFontSize, TelegramBaseFontSize); } private static string ReportResult(string? result) From 87888bec830a637f0676a3ee45e1a65b670b4c10 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 19:39:02 +0700 Subject: [PATCH 155/158] Tests: lock readable eight-column FAT report layout --- .../NativeFatP4DFixedDocumentPreviewTests.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs index f03bc1cf0..ebef9823f 100644 --- a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -151,7 +151,7 @@ public void P4D_ReportUsesSharedSignalNamingAndCustomerFacingCopy() } [Fact] - public void P4D_ReportAdapterLocksExactNineColumnP4CContract() + public void P4D_ReportAdapterUsesReadableEightColumnEvidenceContract() { var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); var snapshot = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); @@ -159,7 +159,6 @@ public void P4D_ReportAdapterLocksExactNineColumnP4CContract() var signal = adapter.IndexOf("\"Signal\"", StringComparison.Ordinal); var telegram = adapter.IndexOf("\"IEC Telegram\"", StringComparison.Ordinal); var quality = adapter.IndexOf("\"Quality\"", StringComparison.Ordinal); - var live = adapter.IndexOf("\"Live Value\"", StringComparison.Ordinal); var value1 = adapter.IndexOf("\"Value 1\"", StringComparison.Ordinal); var timestamp1 = adapter.IndexOf("\"V1 Timestamp\"", StringComparison.Ordinal); var value2 = adapter.IndexOf("\"Value 2\"", StringComparison.Ordinal); @@ -169,13 +168,20 @@ public void P4D_ReportAdapterLocksExactNineColumnP4CContract() Assert.True(signal >= 0); Assert.True(telegram > signal); Assert.True(quality > telegram); - Assert.True(live > quality); - Assert.True(value1 > live); + Assert.True(value1 > quality); Assert.True(timestamp1 > value1); Assert.True(value2 > timestamp1); Assert.True(timestamp2 > value2); Assert.True(result > timestamp2); + Assert.DoesNotContain("\"Live Value\"", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("Clean(row.LiveValue)", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("WrapTelegram(", adapter, StringComparison.Ordinal); + Assert.Contains("private static readonly double[] Widths = [72d, 280d, 44d, 76d, 92d, 76d, 92d, 50d];", adapter, StringComparison.Ordinal); + Assert.Contains("TelegramFontSize(row.IecTelegram)", adapter, StringComparison.Ordinal); + Assert.Contains("MinimumRowHeight = 32d", adapter, StringComparison.Ordinal); + Assert.Contains("isTimestamp ? 5.6d : 6.5d", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("\"Type\"", adapter, StringComparison.Ordinal); Assert.DoesNotContain("\"Status\"", adapter, StringComparison.Ordinal); Assert.DoesNotContain("\"IEC 61850 reference\"", adapter, StringComparison.Ordinal); From d51405abb1e79c08b1b9a84919b2becf0c01779c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 13 Sep 2026 20:03:49 +0700 Subject: [PATCH 156/158] Report: enlarge evidence text and compact table rows --- .../NativeFatAuxiliaryReportDecorator.cs | 51 +++++++++++-------- .../IoTesting/NativeFatP4DReportAdapter.cs | 30 ++++++----- .../NativeFatP4DFixedDocumentPreviewTests.cs | 9 ++-- 3 files changed, 53 insertions(+), 37 deletions(-) diff --git a/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs b/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs index 0c8d66551..deb4ec618 100644 --- a/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs +++ b/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs @@ -13,7 +13,12 @@ internal static class NativeFatAuxiliaryReportDecorator private const double PageHeight = 595d; private const double Margin = 30d; private const double ContentWidth = PageWidth - (Margin * 2d); - private const int ComtradeRowsPerPage = 8; + private const int ComtradeRowsPerPage = 10; + private const double TableHeaderHeight = 26d; + private const double TableRowHeight = 30d; + private const double TableBodyFontSize = 7.2d; + private const double TableMonoFontSize = 6.2d; + private const double TableHeaderFontSize = 6.8d; private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); @@ -84,11 +89,10 @@ private static IoFatReportPagePlan BuildComtradePage( var headers = new[] { "Record Name", "Record Date", "Size", "Result" }; var y = 438d; DrawTableHeader(commands, widths, headers, y); - y -= 28d; + y -= TableHeaderHeight; foreach (var record in records) { - const double rowHeight = 42d; var values = new[] { Fit(record.RecordName, 66), @@ -96,8 +100,8 @@ private static IoFatReportPagePlan BuildComtradePage( FormatSize(record.KnownSizeBytes, record.HasUnknownSize), "OK" }; - DrawRow(commands, widths, values, y, rowHeight, resultColumn: 3); - y -= rowHeight; + DrawRow(commands, widths, values, y, TableRowHeight, resultColumn: 3); + y -= TableRowHeight; } AddFooter(commands, pageNumber, createdAt, "Verified IEC 61850 FileDirectory evidence."); @@ -119,12 +123,12 @@ private static IoFatReportPagePlan BuildTimeSyncPage( "Time Sync OK"); Rect(commands, Margin, 438d, ContentWidth, 68d, 4d, SoftPass, Border, 0.65d); - Text(commands, Margin + 12d, 417d, 110d, "RESULT", IoFatReportFontKind.Bold, 6.1d, Muted); + Text(commands, Margin + 12d, 417d, 110d, "RESULT", IoFatReportFontKind.Bold, 6.4d, Muted); Text(commands, Margin + 12d, 394d, 110d, "OK", IoFatReportFontKind.Bold, 13.2d, Pass); - Text(commands, Margin + 126d, 417d, ContentWidth - 138d, "VERIFICATION BASIS", IoFatReportFontKind.Bold, 6.1d, Muted); + Text(commands, Margin + 126d, 417d, ContentWidth - 138d, "VERIFICATION BASIS", IoFatReportFontKind.Bold, 6.4d, Muted); var summaryLines = Wrap(evidence.Summary, 104, 2); for (var index = 0; index < summaryLines.Count; index++) - Text(commands, Margin + 126d, 399d - (index * 12d), ContentWidth - 138d, summaryLines[index], IoFatReportFontKind.Regular, 7.0d, Ink); + Text(commands, Margin + 126d, 399d - (index * 12d), ContentWidth - 138d, summaryLines[index], IoFatReportFontKind.Regular, 7.4d, Ink); Text( commands, @@ -135,18 +139,17 @@ private static IoFatReportPagePlan BuildTimeSyncPage( ? $"LTMS verified · {evidence.FreshPrimaryTimestampCount:N0} fresh independent IEC timestamp(s)" : $"LTMS not exposed · {evidence.FreshPrimaryTimestampCount:N0} fresh independent IEC timestamps verified", IoFatReportFontKind.Bold, - 7.1d, + 7.4d, Navy); var widths = new[] { 82d, 226d, 92d, 68d, 158d, 88d, 68d }; var headers = new[] { "Evidence", "IEC Reference", "Value", "Quality", "IED Timestamp", "Delta", "Result" }; var y = 330d; DrawTableHeader(commands, widths, headers, y); - y -= 28d; + y -= TableHeaderHeight; foreach (var point in evidence.SupportingPoints) { - const double rowHeight = 48d; var values = new[] { Fit(point.Role, 14), @@ -159,8 +162,8 @@ private static IoFatReportPagePlan BuildTimeSyncPage( : "—", "OK" }; - DrawRow(commands, widths, values, y, rowHeight, resultColumn: 6); - y -= rowHeight; + DrawRow(commands, widths, values, y, TableRowHeight, resultColumn: 6); + y -= TableRowHeight; } AddFooter(commands, pageNumber, createdAt, "Read-only evaluator; SNTP activity alone does not grant OK."); @@ -185,9 +188,9 @@ private static void AddScopeCard( Rect(commands, Margin, 482d, ContentWidth, 34d, 3d, SoftBlue, Border, 0.6d); Text(commands, Margin + 10d, 461d, 390d, $"{Clean(snapshot.IedName)} · {Clean(snapshot.IpAddress)}:{snapshot.Port}", - IoFatReportFontKind.Bold, 7.5d, Ink); - Text(commands, Margin + 350d, 461d, 280d, detail, IoFatReportFontKind.Regular, 6.4d, Muted); - Text(commands, PageWidth - Margin - 118d, 461d, 108d, result, IoFatReportFontKind.Bold, 7.0d, Pass); + IoFatReportFontKind.Bold, 8.0d, Ink); + Text(commands, Margin + 350d, 461d, 280d, detail, IoFatReportFontKind.Regular, 6.8d, Muted); + Text(commands, PageWidth - Margin - 118d, 461d, 108d, result, IoFatReportFontKind.Bold, 7.5d, Pass); } private static void DrawTableHeader( @@ -199,8 +202,8 @@ private static void DrawTableHeader( var x = Margin; for (var index = 0; index < headers.Count; index++) { - Rect(commands, x, y, widths[index], 28d, 0d, SoftBlue, Border, 0.45d); - Text(commands, x + 5d, y - 18d, widths[index] - 10d, headers[index], IoFatReportFontKind.Bold, 6.2d, Blue); + Rect(commands, x, y, widths[index], TableHeaderHeight, 0d, SoftBlue, Border, 0.45d); + Text(commands, x + 5d, CenteredBaseline(y, TableHeaderHeight), widths[index] - 10d, headers[index], IoFatReportFontKind.Bold, TableHeaderFontSize, Blue); x += widths[index]; } } @@ -214,22 +217,26 @@ private static void DrawRow( int resultColumn) { var x = Margin; + var baseline = CenteredBaseline(y, height); for (var index = 0; index < values.Count; index++) { Rect(commands, x, y, widths[index], height, 0d, White, Border, 0.4d); Text( commands, x + 5d, - y - (height / 2d) - 2d, + baseline, widths[index] - 10d, values[index], index == resultColumn ? IoFatReportFontKind.Bold : index is 1 or 4 or 5 ? IoFatReportFontKind.Mono : IoFatReportFontKind.Regular, - index is 1 or 4 or 5 ? 5.6d : 6.4d, + index is 1 or 4 or 5 ? TableMonoFontSize : TableBodyFontSize, index == resultColumn ? Pass : Ink); x += widths[index]; } } + private static double CenteredBaseline(double top, double height) + => top - (height / 2d) - 2d; + private static void AddFooter( ICollection commands, int pageNumber, @@ -239,10 +246,10 @@ private static void AddFooter( Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); Text(commands, Margin, 24d, 620d, $"FAT evidence captured · {createdAt:yyyy-MM-dd HH:mm:ss zzz} | {note}", - IoFatReportFontKind.Regular, 6.2d, Muted); + IoFatReportFontKind.Regular, 6.5d, Muted); Text(commands, PageWidth - Margin - 118d, 24d, 118d, $"Page {pageNumber} / {pageNumber}", - IoFatReportFontKind.Regular, 6.2d, Muted); + IoFatReportFontKind.Regular, 6.5d, Muted); } private static string FormatSize(long knownSizeBytes, bool hasUnknownSize) diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs index 5cad8a9ae..de92234ea 100644 --- a/Services/IoTesting/NativeFatP4DReportAdapter.cs +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -13,10 +13,12 @@ internal static class NativeFatP4DReportAdapter private const double Margin = 30d; private const double ContentTop = 466d; private const double ContentBottom = 52d; - private const double HeaderHeight = 25d; - private const double MinimumRowHeight = 32d; - private const double TelegramBaseFontSize = 6.2d; - private const double TelegramMinimumFontSize = 4.6d; + private const double HeaderHeight = 26d; + private const double TableRowHeight = 30d; + private const double TableBodyFontSize = 7.2d; + private const double TableTimestampFontSize = 6.2d; + private const double TelegramBaseFontSize = 6.8d; + private const double TelegramMinimumFontSize = 5.2d; // Customer-facing evidence table. Total width = 782 pt (842 - 2 * 30 margin). // Live Value is intentionally omitted from the report: FAT evidence is Value 1 / Value 2. @@ -80,7 +82,7 @@ public static IoFatReportLayoutPlan Build(NativeFatPrintPreviewSnapshot snapshot 520d, $"FAT evidence captured · {snapshot.CapturedAt:yyyy-MM-dd HH:mm:ss zzz}", IoFatReportFontKind.Regular, - 6.2d, + 6.5d, Muted)); pages[index].Add(new IoFatReportTextCommand( PageWidth - Margin - 100d, @@ -88,7 +90,7 @@ public static IoFatReportLayoutPlan Build(NativeFatPrintPreviewSnapshot snapshot 100d, $"Page {index + 1} / {pages.Count}", IoFatReportFontKind.Regular, - 6.2d, + 6.5d, Muted)); } @@ -169,11 +171,11 @@ private static void DrawTableHeader(List page, ref double y) page.Add(new IoFatReportRectCommand(x, y, Widths[index], HeaderHeight, 0d, SoftBlue, Border, 0.45d)); page.Add(new IoFatReportTextCommand( x + 4d, - y - 16.5d, + CenteredBaseline(y, HeaderHeight), Widths[index] - 8d, Headers[index], IoFatReportFontKind.Bold, - index is 4 or 6 ? 5.8d : 6.4d, + index is 4 or 6 ? 6.2d : 6.8d, Blue)); x += Widths[index]; } @@ -181,7 +183,7 @@ private static void DrawTableHeader(List page, ref double y) } private static double GetRowHeight(NativeFatPrintPreviewRow row) - => MinimumRowHeight; + => TableRowHeight; private static void DrawRow( List page, @@ -202,6 +204,7 @@ private static void DrawRow( reportResult }; + var baseline = CenteredBaseline(y, height); var x = Margin; for (var index = 0; index < cells.Length; index++) { @@ -211,7 +214,7 @@ private static void DrawRow( { page.Add(new IoFatReportTextCommand( x + 4d, - y - 19d, + baseline, Widths[index] - 8d, Clean(row.IecTelegram), IoFatReportFontKind.Mono, @@ -223,13 +226,13 @@ private static void DrawRow( var isTimestamp = index is 4 or 6; page.Add(new IoFatReportTextCommand( x + 4d, - y - 19d, + baseline, Widths[index] - 8d, cells[index], isTimestamp ? IoFatReportFontKind.Mono : index is 0 or 7 ? IoFatReportFontKind.Bold : IoFatReportFontKind.Regular, - isTimestamp ? 5.6d : 6.5d, + isTimestamp ? TableTimestampFontSize : TableBodyFontSize, index == 7 ? ResultColor(reportResult) : Ink)); } @@ -239,6 +242,9 @@ private static void DrawRow( y -= height; } + private static double CenteredBaseline(double top, double height) + => top - (height / 2d) - 2d; + private static double TelegramFontSize(string? value) { var text = Clean(value); diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs index ebef9823f..82c8893da 100644 --- a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -179,8 +179,11 @@ public void P4D_ReportAdapterUsesReadableEightColumnEvidenceContract() Assert.DoesNotContain("WrapTelegram(", adapter, StringComparison.Ordinal); Assert.Contains("private static readonly double[] Widths = [72d, 280d, 44d, 76d, 92d, 76d, 92d, 50d];", adapter, StringComparison.Ordinal); Assert.Contains("TelegramFontSize(row.IecTelegram)", adapter, StringComparison.Ordinal); - Assert.Contains("MinimumRowHeight = 32d", adapter, StringComparison.Ordinal); - Assert.Contains("isTimestamp ? 5.6d : 6.5d", adapter, StringComparison.Ordinal); + Assert.Contains("TableRowHeight = 30d", adapter, StringComparison.Ordinal); + Assert.Contains("TableBodyFontSize = 7.2d", adapter, StringComparison.Ordinal); + Assert.Contains("TableTimestampFontSize = 6.2d", adapter, StringComparison.Ordinal); + Assert.Contains("TelegramBaseFontSize = 6.8d", adapter, StringComparison.Ordinal); + Assert.Contains("CenteredBaseline(y, height)", adapter, StringComparison.Ordinal); Assert.DoesNotContain("\"Type\"", adapter, StringComparison.Ordinal); Assert.DoesNotContain("\"Status\"", adapter, StringComparison.Ordinal); @@ -240,4 +243,4 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException( $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } -} \ No newline at end of file +} From 68d0daf2c8ba970e80c1de3b9e1ce8890cd0f345 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 04:45:57 +0700 Subject: [PATCH 157/158] Report: harden customer wording and local timestamps --- .../NativeFatAuxiliaryReportDecorator.cs | 35 ++++++++------- .../IoTesting/NativeFatP4DReportAdapter.cs | 37 ++++++++-------- .../NativeFatPrintPreviewSnapshot.cs | 5 +-- .../IoTesting/NativeFatReportFinalization.cs | 39 +++++++++-------- .../IoTesting/NativeFatReportFormatting.cs | 43 +++++++++++++++++++ .../NativeFatP4DFixedDocumentPreviewTests.cs | 31 ++++++++----- .../NativeFatPatchBAuxiliaryEvidenceTests.cs | 39 +++++++++-------- 7 files changed, 146 insertions(+), 83 deletions(-) create mode 100644 Services/IoTesting/NativeFatReportFormatting.cs diff --git a/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs b/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs index deb4ec618..28b4eb903 100644 --- a/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs +++ b/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs @@ -77,16 +77,16 @@ private static IoFatReportPagePlan BuildComtradePage( var commands = new List(); AddHeader( commands, - "IEC 61850 Fault Record (COMTRADE)", + "IEC 61850 Fault Records (COMTRADE)", continued ? "Available Fault Records · continued" : "Available Fault Records"); AddScopeCard( commands, snapshot, - $"FileDirectory verified · {verifiedAtUtc.ToUniversalTime():yyyy-MM-dd HH:mm:ss 'UTC'}", - $"{snapshot.AuxiliaryEvidence.ComtradeRecords.Count:N0} record(s)"); + $"Fault record directory verified · {NativeFatReportFormatting.LocalTimestamp(verifiedAtUtc)} · Local time", + RecordCountText(snapshot.AuxiliaryEvidence.ComtradeRecords.Count)); var widths = new[] { 330d, 190d, 160d, 102d }; - var headers = new[] { "Record Name", "Record Date", "Size", "Result" }; + var headers = new[] { "Record Name", "File Timestamp", "Total Size", "Status" }; var y = 438d; DrawTableHeader(commands, widths, headers, y); y -= TableHeaderHeight; @@ -96,15 +96,15 @@ private static IoFatReportPagePlan BuildComtradePage( var values = new[] { Fit(record.RecordName, 66), - record.RecordDateUtc?.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "—", + NativeFatReportFormatting.LocalTimestamp(record.RecordDateUtc), FormatSize(record.KnownSizeBytes, record.HasUnknownSize), - "OK" + "Complete" }; DrawRow(commands, widths, values, y, TableRowHeight, resultColumn: 3); y -= TableRowHeight; } - AddFooter(commands, pageNumber, createdAt, "Verified IEC 61850 FileDirectory evidence."); + AddFooter(commands, pageNumber, createdAt, "Fault record directory verified via IEC 61850 file services."); return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); } @@ -119,7 +119,7 @@ private static IoFatReportPagePlan BuildTimeSyncPage( AddScopeCard( commands, snapshot, - $"Evaluated · {evidence.VerifiedAtUtc.ToUniversalTime():yyyy-MM-dd HH:mm:ss 'UTC'}", + $"Evaluated · {NativeFatReportFormatting.LocalTimestamp(evidence.VerifiedAtUtc)} · Local time", "Time Sync OK"); Rect(commands, Margin, 438d, ContentWidth, 68d, 4d, SoftPass, Border, 0.65d); @@ -143,7 +143,7 @@ private static IoFatReportPagePlan BuildTimeSyncPage( Navy); var widths = new[] { 82d, 226d, 92d, 68d, 158d, 88d, 68d }; - var headers = new[] { "Evidence", "IEC Reference", "Value", "Quality", "IED Timestamp", "Delta", "Result" }; + var headers = new[] { "Evidence", "IEC 61850 Reference", "Value", "Quality", "IED Timestamp", "Delta", "Result" }; var y = 330d; DrawTableHeader(commands, widths, headers, y); y -= TableHeaderHeight; @@ -155,8 +155,8 @@ private static IoFatReportPagePlan BuildTimeSyncPage( Fit(point.Role, 14), Fit(FirstNonEmpty(point.IecReference, point.SignalName), 42), Fit(point.Value, 16), - Fit(point.Quality, 12), - Fit(point.DeviceTimestamp, 28), + Fit(NativeFatReportFormatting.Quality(point.Quality), 12), + Fit(NativeFatReportFormatting.LocalTimestamp(point.DeviceTimestamp), 28), point.DeltaSeconds.HasValue ? $"{point.DeltaSeconds.Value:0.000} s" : "—", @@ -186,10 +186,10 @@ private static void AddScopeCard( string result) { Rect(commands, Margin, 482d, ContentWidth, 34d, 3d, SoftBlue, Border, 0.6d); - Text(commands, Margin + 10d, 461d, 390d, - $"{Clean(snapshot.IedName)} · {Clean(snapshot.IpAddress)}:{snapshot.Port}", + Text(commands, Margin + 10d, 461d, 300d, + $"IED: {Clean(snapshot.IedName)} · Endpoint: {Clean(snapshot.IpAddress)}:{snapshot.Port}", IoFatReportFontKind.Bold, 8.0d, Ink); - Text(commands, Margin + 350d, 461d, 280d, detail, IoFatReportFontKind.Regular, 6.8d, Muted); + Text(commands, Margin + 310d, 461d, 340d, detail, IoFatReportFontKind.Regular, 6.6d, Muted); Text(commands, PageWidth - Margin - 118d, 461d, 108d, result, IoFatReportFontKind.Bold, 7.5d, Pass); } @@ -244,14 +244,17 @@ private static void AddFooter( string note) { Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); - Text(commands, Margin, 24d, 620d, - $"FAT evidence captured · {createdAt:yyyy-MM-dd HH:mm:ss zzz} | {note}", + Text(commands, Margin, 24d, 650d, + $"FAT evidence captured · {NativeFatReportFormatting.LocalTimestamp(createdAt)} · Local time | {note}", IoFatReportFontKind.Regular, 6.5d, Muted); Text(commands, PageWidth - Margin - 118d, 24d, 118d, $"Page {pageNumber} / {pageNumber}", IoFatReportFontKind.Regular, 6.5d, Muted); } + private static string RecordCountText(int count) + => count == 1 ? "1 record" : $"{count:N0} records"; + private static string FormatSize(long knownSizeBytes, bool hasUnknownSize) { var prefix = hasUnknownSize ? ">= " : string.Empty; diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs index de92234ea..7a67a103f 100644 --- a/Services/IoTesting/NativeFatP4DReportAdapter.cs +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -22,11 +22,11 @@ internal static class NativeFatP4DReportAdapter // Customer-facing evidence table. Total width = 782 pt (842 - 2 * 30 margin). // Live Value is intentionally omitted from the report: FAT evidence is Value 1 / Value 2. - // The reclaimed width is prioritized for IEC Telegram and evidence timestamps so the - // printable table stays readable while the IEC identity remains on one line. - private static readonly double[] Widths = [72d, 280d, 44d, 76d, 92d, 76d, 92d, 50d]; + // IEC 61850 Reference keeps the dominant width; the status column is widened enough for + // the explicit customer-facing "Evidence Status" wording without sacrificing timestamps. + private static readonly double[] Widths = [66d, 280d, 40d, 72d, 92d, 72d, 92d, 68d]; private static readonly string[] Headers = - ["Signal", "IEC Telegram", "Quality", "Value 1", "V1 Timestamp", "Value 2", "V2 Timestamp", "Result"]; + ["Signal", "IEC 61850 Reference", "Quality", "Value 1", "V1 Timestamp", "Value 2", "V2 Timestamp", "Evidence Status"]; private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); @@ -79,8 +79,8 @@ public static IoFatReportLayoutPlan Build(NativeFatPrintPreviewSnapshot snapshot pages[index].Add(new IoFatReportTextCommand( Margin, 24d, - 520d, - $"FAT evidence captured · {snapshot.CapturedAt:yyyy-MM-dd HH:mm:ss zzz}", + 560d, + $"FAT evidence captured · {NativeFatReportFormatting.LocalTimestamp(snapshot.CapturedAt)} · Local time", IoFatReportFontKind.Regular, 6.5d, Muted)); @@ -128,7 +128,7 @@ private static List NewPage( Margin, 542d, 560d, - "Factory Acceptance Test · IEC 61850 signal evidence", + "Factory Acceptance Test · IEC 61850 Signal Evidence", IoFatReportFontKind.Regular, 7.6d, Muted)); @@ -146,15 +146,15 @@ private static List NewPage( 499d, 470d, continued - ? $"{Clean(snapshot.IedName)} (continued) · {Clean(snapshot.IpAddress)}:{snapshot.Port}" - : $"{Clean(snapshot.IedName)} · {Clean(snapshot.IpAddress)}:{snapshot.Port}", + ? $"IED: {Clean(snapshot.IedName)} (continued) · Endpoint: {Clean(snapshot.IpAddress)}:{snapshot.Port}" + : $"IED: {Clean(snapshot.IedName)} · Endpoint: {Clean(snapshot.IpAddress)}:{snapshot.Port}", IoFatReportFontKind.Bold, 8.6d, Ink)); page.Add(new IoFatReportTextCommand( - PageWidth - Margin - 220d, + PageWidth - Margin - 250d, 499d, - 208d, + 238d, snapshot.ProgressText, IoFatReportFontKind.Bold, 8.2d, @@ -175,7 +175,7 @@ private static void DrawTableHeader(List page, ref double y) Widths[index] - 8d, Headers[index], IoFatReportFontKind.Bold, - index is 4 or 6 ? 6.2d : 6.8d, + index is 4 or 6 ? 6.2d : index is 1 or 7 ? 6.4d : 6.8d, Blue)); x += Widths[index]; } @@ -196,7 +196,7 @@ private static void DrawRow( { Clean(row.Signal), string.Empty, - Clean(row.Quality), + NativeFatReportFormatting.Quality(row.Quality), Clean(row.Value1), Clean(row.Value1TimestampText), Clean(row.Value2), @@ -251,9 +251,6 @@ private static double TelegramFontSize(string? value) if (text.Length == 0) return TelegramBaseFontSize; - // Conservative monospace estimate: ~0.62 em per glyph. The normal case keeps the - // larger readable size; unusually long IEC references shrink only as much as needed - // to stay on one physical report row instead of wrapping or clipping. var availableWidth = Widths[1] - 8d; var fitted = availableWidth / (text.Length * 0.62d); return Math.Clamp(fitted, TelegramMinimumFontSize, TelegramBaseFontSize); @@ -262,13 +259,17 @@ private static double TelegramFontSize(string? value) private static string ReportResult(string? result) { var value = Clean(result); - return value.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase) ? "OK" : value; + return value.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase) || + value.Equals("OK", StringComparison.OrdinalIgnoreCase) + ? "Complete" + : value; } private static IoFatReportColor ResultColor(string? result) { var value = Clean(result); - if (value.Equals("OK", StringComparison.OrdinalIgnoreCase) || + if (value.Equals("Complete", StringComparison.OrdinalIgnoreCase) || + value.Equals("OK", StringComparison.OrdinalIgnoreCase) || value.Contains("PASS", StringComparison.OrdinalIgnoreCase) || value.Contains("COMPLETE", StringComparison.OrdinalIgnoreCase)) return Pass; diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs index 9e435ea62..a35de1c98 100644 --- a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Globalization; using ArIED61850Tester.Models; using ArIED61850Tester.Models.IoTesting; @@ -51,7 +50,7 @@ private NativeFatPrintPreviewSnapshot( public IReadOnlyList Rows => _rows; public NativeFatAuxiliaryEvidenceSnapshot AuxiliaryEvidence { get; } public int CompleteCount => _rows.Count(row => HasEvidence(row.Value1) && HasEvidence(row.Value2)); - public string ProgressText => $"{CompleteCount}/{_rows.Count} complete"; + public string ProgressText => $"Evidence complete: {CompleteCount} / {_rows.Count} signals"; public static NativeFatPrintPreviewSnapshot Capture( Iec61850MonitorDevice device, @@ -100,7 +99,7 @@ private static string DisplayTimestamp(FatValueEvidence? evidence) if (evidence is null) return "—"; var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; - return timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + return NativeFatReportFormatting.LocalTimestamp(timestamp); } private static bool HasEvidence(string? value) diff --git a/Services/IoTesting/NativeFatReportFinalization.cs b/Services/IoTesting/NativeFatReportFinalization.cs index 8f2fc4539..8729263d2 100644 --- a/Services/IoTesting/NativeFatReportFinalization.cs +++ b/Services/IoTesting/NativeFatReportFinalization.cs @@ -15,7 +15,6 @@ internal static class NativeFatReportFinalization private const double ContentWidth = PageWidth - (Margin * 2d); private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); - private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); private static readonly IoFatReportColor SoftBlue = IoFatReportColor.FromHex("EFF6FF"); private static readonly IoFatReportColor Border = IoFatReportColor.FromHex("D9E4F0"); private static readonly IoFatReportColor Muted = IoFatReportColor.FromHex("64748B"); @@ -55,30 +54,30 @@ private static IoFatReportPagePlan BuildSignOffPage( NativeFatReportBranding.AddLogo(commands, PageWidth - Margin - 102d, 582d); Text(commands, Margin, 566d, 490d, "IEC 61850 FAT", IoFatReportFontKind.Bold, 7.2d, Muted); Text(commands, Margin, 544d, 520d, "FAT Acceptance Sign-Off", IoFatReportFontKind.Bold, 17.2d, Navy); - Text(commands, Margin, 522d, 540d, - "Final acceptance record for the IEC 61850 FAT evidence in this report.", + Text(commands, Margin, 522d, 570d, + "Acceptance sign-off for the IEC 61850 FAT evidence documented in this report.", IoFatReportFontKind.Regular, 8.0d, Muted); Line(commands, Margin, 498d, PageWidth - Margin, 498d, Border, 0.8d); - // Keep the upper-right header clear for the report logo. IED identity is already - // carried by the preceding evidence pages; duplicating a scope card here caused a - // visual collision and added no sign-off evidence. - Text(commands, Margin, 470d, ContentWidth, - "By signing below, the parties acknowledge the FAT execution and evidence recorded in the preceding pages.", - IoFatReportFontKind.Regular, 7.2d, Ink); + Text(commands, Margin, 478d, ContentWidth, + $"IED: {Clean(snapshot.IedName)} · Report: IEC 61850 FAT Evidence", + IoFatReportFontKind.Bold, 7.4d, Navy); + Text(commands, Margin, 458d, ContentWidth, + "By signing below, the undersigned confirm that they have reviewed the FAT execution and evidence documented in this report in accordance with their respective roles.", + IoFatReportFontKind.Regular, 7.0d, Ink); const double gap = 14d; var boxWidth = (ContentWidth - (gap * 2d)) / 3d; var x = Margin; foreach (var heading in new[] { "TESTED BY", "WITNESSED BY", "APPROVED BY" }) { - DrawSignOffBox(commands, x, 430d, boxWidth, 286d, heading); + DrawSignOffBox(commands, x, 414d, boxWidth, 282d, heading); x += boxWidth + gap; } Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); Text(commands, Margin, 24d, 620d, - $"FAT evidence captured · {createdAt:yyyy-MM-dd HH:mm:ss zzz}", + $"FAT evidence captured · {NativeFatReportFormatting.LocalTimestamp(createdAt)} · Local time", IoFatReportFontKind.Regular, 6.2d, Muted); Text(commands, PageWidth - Margin - 118d, 24d, 118d, $"Page {pageNumber} / {totalPages}", @@ -101,14 +100,16 @@ private static void DrawSignOffBox( var lineX = x + 12d; var lineRight = x + width - 12d; - Text(commands, lineX, top - 63d, width - 24d, "Name", IoFatReportFontKind.Bold, 6.1d, Muted); - Line(commands, lineX, top - 92d, lineRight, top - 92d, Border, 0.65d); - Text(commands, lineX, top - 115d, width - 24d, "Company / Organization", IoFatReportFontKind.Bold, 6.1d, Muted); - Line(commands, lineX, top - 144d, lineRight, top - 144d, Border, 0.65d); - Text(commands, lineX, top - 168d, width - 24d, "Signature", IoFatReportFontKind.Bold, 6.1d, Muted); - Rect(commands, lineX, top - 183d, width - 24d, 54d, 0d, White, Border, 0.55d); - Text(commands, lineX, top - 255d, width - 24d, "Date", IoFatReportFontKind.Bold, 6.1d, Muted); - Line(commands, lineX, top - 275d, lineRight, top - 275d, Border, 0.65d); + Text(commands, lineX, top - 56d, width - 24d, "Name", IoFatReportFontKind.Bold, 6.3d, Muted); + Line(commands, lineX, top - 78d, lineRight, top - 78d, Border, 0.65d); + Text(commands, lineX, top - 101d, width - 24d, "Title / Role", IoFatReportFontKind.Bold, 6.3d, Muted); + Line(commands, lineX, top - 123d, lineRight, top - 123d, Border, 0.65d); + Text(commands, lineX, top - 146d, width - 24d, "Company / Organization", IoFatReportFontKind.Bold, 6.3d, Muted); + Line(commands, lineX, top - 168d, lineRight, top - 168d, Border, 0.65d); + Text(commands, lineX, top - 191d, width - 24d, "Signature", IoFatReportFontKind.Bold, 6.3d, Muted); + Rect(commands, lineX, top - 204d, width - 24d, 42d, 0d, White, Border, 0.55d); + Text(commands, lineX, top - 257d, width - 24d, "Date", IoFatReportFontKind.Bold, 6.3d, Muted); + Line(commands, lineX, top - 276d, lineRight, top - 276d, Border, 0.65d); } private static IoFatReportCommand CorrectPageTotal(IoFatReportCommand command, int pageNumber, int totalPages) diff --git a/Services/IoTesting/NativeFatReportFormatting.cs b/Services/IoTesting/NativeFatReportFormatting.cs new file mode 100644 index 000000000..404f2ea71 --- /dev/null +++ b/Services/IoTesting/NativeFatReportFormatting.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Customer-facing formatting authority shared by every native FAT report section. +/// Runtime/cache timestamps keep their original identity; only report presentation is +/// converted to the workstation's local time and rendered with one unambiguous format. +/// +internal static class NativeFatReportFormatting +{ + internal const string LocalTimestampFormat = "dd/MM/yyyy HH:mm:ss.fff"; + + internal static string LocalTimestamp(DateTimeOffset value) + => value.ToLocalTime().ToString(LocalTimestampFormat, CultureInfo.InvariantCulture); + + internal static string LocalTimestamp(DateTimeOffset? value) + => value.HasValue ? LocalTimestamp(value.Value) : "—"; + + internal static string LocalTimestamp(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "—"; + + var text = value.Trim(); + return DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces, + out var timestamp) + ? LocalTimestamp(timestamp) + : text; + } + + internal static string Quality(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "—"; + + var text = value.Trim(); + return text.Equals("good", StringComparison.OrdinalIgnoreCase) ? "Good" : text; + } +} diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs index 82c8893da..7b490c1f6 100644 --- a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -108,17 +108,20 @@ public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild Assert.Equal("52_ACB1 Status", snapshot.Rows[0].Signal); Assert.Equal("COMPLETE", snapshot.Rows[0].Result); Assert.Equal("Open [01]", snapshot.Rows[0].Value1); - Assert.False(string.IsNullOrWhiteSpace(snapshot.Rows[0].Value1TimestampText)); + Assert.Matches(@"^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}\.\d{3}$", snapshot.Rows[0].Value1TimestampText); Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); - Assert.False(string.IsNullOrWhiteSpace(snapshot.Rows[0].Value2TimestampText)); - Assert.Contains("OK", reportText); + Assert.Matches(@"^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}\.\d{3}$", snapshot.Rows[0].Value2TimestampText); + Assert.Contains("Complete", reportText); Assert.DoesNotContain("COMPLETE", reportText); + Assert.Contains("Evidence complete: 1 / 1 signals", reportText); Assert.Contains("ARSAS", reportText); Assert.DoesNotContain(reportText, text => text.Contains("COMTRADE", StringComparison.OrdinalIgnoreCase)); Assert.True(layout.Pages.Count >= 2); Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "TESTED BY"); Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "WITNESSED BY"); Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "APPROVED BY"); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "Title / Role"); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "IED: AA1E1F06R4 · Report: IEC 61850 FAT Evidence"); Assert.True(bytes.Length > 32); Assert.Equal("%PDF-1.4", Encoding.ASCII.GetString(bytes, 0, 8)); } @@ -130,12 +133,18 @@ public void P4D_ReportUsesSharedSignalNamingAndCustomerFacingCopy() var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); var finalization = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportFinalization.cs")); var branding = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportBranding.cs")); + var formatting = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportFormatting.cs")); Assert.Contains("IoFatSignalDisplayNameFormatter.Format(point.SignalName, point.IecReference)", snapshot, StringComparison.Ordinal); Assert.Contains("NativeFatReportBranding.AddLogo", adapter, StringComparison.Ordinal); Assert.Contains("NativeFatReportBranding.AddLogo", finalization, StringComparison.Ordinal); Assert.Contains("\"ARSAS\"", branding, StringComparison.Ordinal); - Assert.Contains("return value.Equals(\"COMPLETE\", StringComparison.OrdinalIgnoreCase) ? \"OK\" : value;", adapter, StringComparison.Ordinal); + Assert.Contains("? \"Complete\"", adapter, StringComparison.Ordinal); + Assert.Contains("Evidence complete:", snapshot, StringComparison.Ordinal); + Assert.Contains("dd/MM/yyyy HH:mm:ss.fff", formatting, StringComparison.Ordinal); + Assert.Contains("ToLocalTime()", formatting, StringComparison.Ordinal); + Assert.Contains("Acceptance sign-off for the IEC 61850 FAT evidence documented in this report.", finalization, StringComparison.Ordinal); + Assert.Contains("Title / Role", finalization, StringComparison.Ordinal); foreach (var internalCopy in new[] { @@ -157,27 +166,28 @@ public void P4D_ReportAdapterUsesReadableEightColumnEvidenceContract() var snapshot = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); var signal = adapter.IndexOf("\"Signal\"", StringComparison.Ordinal); - var telegram = adapter.IndexOf("\"IEC Telegram\"", StringComparison.Ordinal); + var reference = adapter.IndexOf("\"IEC 61850 Reference\"", StringComparison.Ordinal); var quality = adapter.IndexOf("\"Quality\"", StringComparison.Ordinal); var value1 = adapter.IndexOf("\"Value 1\"", StringComparison.Ordinal); var timestamp1 = adapter.IndexOf("\"V1 Timestamp\"", StringComparison.Ordinal); var value2 = adapter.IndexOf("\"Value 2\"", StringComparison.Ordinal); var timestamp2 = adapter.IndexOf("\"V2 Timestamp\"", StringComparison.Ordinal); - var result = adapter.IndexOf("\"Result\"", StringComparison.Ordinal); + var status = adapter.IndexOf("\"Evidence Status\"", StringComparison.Ordinal); Assert.True(signal >= 0); - Assert.True(telegram > signal); - Assert.True(quality > telegram); + Assert.True(reference > signal); + Assert.True(quality > reference); Assert.True(value1 > quality); Assert.True(timestamp1 > value1); Assert.True(value2 > timestamp1); Assert.True(timestamp2 > value2); - Assert.True(result > timestamp2); + Assert.True(status > timestamp2); Assert.DoesNotContain("\"Live Value\"", adapter, StringComparison.Ordinal); Assert.DoesNotContain("Clean(row.LiveValue)", adapter, StringComparison.Ordinal); Assert.DoesNotContain("WrapTelegram(", adapter, StringComparison.Ordinal); - Assert.Contains("private static readonly double[] Widths = [72d, 280d, 44d, 76d, 92d, 76d, 92d, 50d];", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("\"IEC Telegram\"", adapter, StringComparison.Ordinal); + Assert.Contains("private static readonly double[] Widths = [66d, 280d, 40d, 72d, 92d, 72d, 92d, 68d];", adapter, StringComparison.Ordinal); Assert.Contains("TelegramFontSize(row.IecTelegram)", adapter, StringComparison.Ordinal); Assert.Contains("TableRowHeight = 30d", adapter, StringComparison.Ordinal); Assert.Contains("TableBodyFontSize = 7.2d", adapter, StringComparison.Ordinal); @@ -197,6 +207,7 @@ public void P4D_ReportAdapterUsesReadableEightColumnEvidenceContract() Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadCapture", snapshot, StringComparison.Ordinal); Assert.Contains("DisplayTimestamp(capture1)", snapshot, StringComparison.Ordinal); Assert.Contains("DisplayTimestamp(capture2)", snapshot, StringComparison.Ordinal); + Assert.Contains("NativeFatReportFormatting.LocalTimestamp(timestamp)", snapshot, StringComparison.Ordinal); Assert.DoesNotContain("string Type", snapshot, StringComparison.Ordinal); Assert.DoesNotContain("string Status", snapshot, StringComparison.Ordinal); } diff --git a/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs b/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs index 8c2edf146..3f2de8e1c 100644 --- a/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs +++ b/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using AR.Iec61850.FaultRecords; using ArIED61850Tester.Models; using ArIED61850Tester.Services.IoTesting; @@ -40,11 +41,10 @@ public void ComtradeVerifiedRecords_AppearWithRequiredColumnsAndImmutableValues( Files = files } }; + var verifiedAt = new DateTimeOffset(2026, 9, 10, 8, 32, 0, TimeSpan.Zero); + var recordAt = new DateTimeOffset(2026, 9, 10, 8, 31, 0, TimeSpan.Zero); var cache = new NativeFatAuxiliaryEvidenceCache(); - cache.RecordComtradeDiscovery( - device, - records, - new DateTimeOffset(2026, 9, 10, 8, 32, 0, TimeSpan.Zero)); + cache.RecordComtradeDiscovery(device, records, verifiedAt); var auxiliary = cache.Capture(device); records.Clear(); @@ -59,16 +59,20 @@ public void ComtradeVerifiedRecords_AppearWithRequiredColumnsAndImmutableValues( Assert.Single(snapshot.AuxiliaryEvidence.ComtradeRecords); Assert.Equal("FAULT_001", snapshot.AuxiliaryEvidence.ComtradeRecords[0].RecordName); - Assert.Contains("IEC 61850 Fault Record (COMTRADE)", text); + Assert.Contains("IEC 61850 Fault Records (COMTRADE)", text); Assert.Contains("Available Fault Records", text); Assert.Contains("Record Name", text); - Assert.Contains("Record Date", text); - Assert.Contains("Size", text); - Assert.Contains("Result", text); + Assert.Contains("File Timestamp", text); + Assert.Contains("Total Size", text); + Assert.Contains("Status", text); Assert.Contains("FAULT_001", text); - Assert.Contains("2026-09-10 08:31:00 UTC", text); + Assert.Contains(recordAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss.fff", CultureInfo.InvariantCulture), text); + Assert.Contains(text, value => value.Contains( + $"Fault record directory verified · {verifiedAt.ToLocalTime():dd/MM/yyyy HH:mm:ss.fff}", + StringComparison.Ordinal)); Assert.Contains("1.5 KB", text); - Assert.Contains("OK", text); + Assert.Contains("Complete", text); + Assert.DoesNotContain(text, value => value.Contains(" UTC", StringComparison.Ordinal)); Assert.Equal("%PDF-1.4", System.Text.Encoding.ASCII.GetString(pdf, 0, 8)); for (var index = 0; index < layout.Pages.Count; index++) { @@ -86,7 +90,7 @@ public void ComtradeFailedOrEmptyDiscovery_OmitsWholeSection() var cache = new NativeFatAuxiliaryEvidenceCache(); Assert.DoesNotContain( - "IEC 61850 Fault Record (COMTRADE)", + "IEC 61850 Fault Records (COMTRADE)", ReportText(Build(device, cache))); cache.RecordComtradeDiscovery( @@ -94,13 +98,13 @@ public void ComtradeFailedOrEmptyDiscovery_OmitsWholeSection() [new Iec61850FaultRecordSet { RecordId = "EMPTY", BaseName = "EMPTY" }], DateTimeOffset.UtcNow); Assert.DoesNotContain( - "IEC 61850 Fault Record (COMTRADE)", + "IEC 61850 Fault Records (COMTRADE)", ReportText(Build(device, cache))); cache.RecordComtradeDiscovery(device, [ValidRecord("FAULT_002")], DateTimeOffset.UtcNow); cache.ClearComtrade(device); Assert.DoesNotContain( - "IEC 61850 Fault Record (COMTRADE)", + "IEC 61850 Fault Records (COMTRADE)", ReportText(Build(device, cache))); } @@ -114,11 +118,9 @@ public void TimeSyncOk_AppearsWithOnlyBoundedSupportingEvidence() "OK", "LTMS evidence is present and cross-checked by a fresh good-quality IEC timestamp.", false); + var evaluatedAt = new DateTimeOffset(2026, 9, 10, 8, 40, 0, TimeSpan.Zero); - cache.RecordTimeSyncEvaluation( - device, - diagnostic, - new DateTimeOffset(2026, 9, 10, 8, 40, 0, TimeSpan.Zero)); + cache.RecordTimeSyncEvaluation(device, diagnostic, evaluatedAt); var snapshot = NativeFatPrintPreviewSnapshot.Capture( device, new NativeFatIedSessionCacheState(), @@ -130,9 +132,12 @@ public void TimeSyncOk_AppearsWithOnlyBoundedSupportingEvidence() Assert.Equal(2, snapshot.AuxiliaryEvidence.TimeSync.SupportingPoints.Count); Assert.Contains("IEC 61850 Time Synchronization Evidence", text); Assert.Contains("Time Sync OK", text); + Assert.Contains("IEC 61850 Reference", text); Assert.Contains(text, value => value.Contains("LTMS verified", StringComparison.Ordinal)); Assert.Contains("AA1E1F06R4LD0/LLN0.LTMS", text); Assert.Contains("AA1E1F06R4LD0/XCBR1.Pos.stVal", text); + Assert.Contains(evaluatedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss.fff", CultureInfo.InvariantCulture), text); + Assert.DoesNotContain(text, value => value.Contains(" UTC", StringComparison.Ordinal)); } [Theory] From 08185c9536840efafccd11c26669c7104abc9022 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 04:54:55 +0700 Subject: [PATCH 158/158] Tests: align report wording and local timestamp regressions --- Services/IoTesting/NativeFatP4DReportAdapter.cs | 2 +- .../NativeFatEvidenceDurabilityRegressionTests.cs | 10 ++++++++-- tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs | 4 ++-- .../NativeFatP4EEvidenceIsolationRegressionTests.cs | 7 +++++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs index 7a67a103f..f731098be 100644 --- a/Services/IoTesting/NativeFatP4DReportAdapter.cs +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -23,7 +23,7 @@ internal static class NativeFatP4DReportAdapter // Customer-facing evidence table. Total width = 782 pt (842 - 2 * 30 margin). // Live Value is intentionally omitted from the report: FAT evidence is Value 1 / Value 2. // IEC 61850 Reference keeps the dominant width; the status column is widened enough for - // the explicit customer-facing "Evidence Status" wording without sacrificing timestamps. + // the explicit customer-facing Evidence Status wording without sacrificing timestamps. private static readonly double[] Widths = [66d, 280d, 40d, 72d, 92d, 72d, 92d, 68d]; private static readonly string[] Headers = ["Signal", "IEC 61850 Reference", "Quality", "Value 1", "V1 Timestamp", "Value 2", "V2 Timestamp", "Evidence Status"]; diff --git a/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs b/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs index 88228adb1..33c91368e 100644 --- a/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs @@ -114,10 +114,16 @@ public async Task IedOwnedStore_LoadsBeforeRowsExist_AndSurvivesImmediateTeardow var preview = NativeFatPrintPreviewSnapshot.Capture(after, restored); var previewCswi = Assert.Single( preview.Rows.Where(row => row.IecTelegram.Equals(cswiAfter.IecTelegram, StringComparison.OrdinalIgnoreCase))); + var expectedV1 = DateTimeOffset.Parse("2026-09-13T14:10:11.123+07:00") + .ToLocalTime() + .ToString("dd/MM/yyyy HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); + var expectedV2 = DateTimeOffset.Parse("2026-09-13T14:10:19.456+07:00") + .ToLocalTime() + .ToString("dd/MM/yyyy HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); Assert.Equal("Closed [10]", previewCswi.Value1); - Assert.Equal("2026-09-13 14:10:11.123", previewCswi.Value1TimestampText); + Assert.Equal(expectedV1, previewCswi.Value1TimestampText); Assert.Equal("Open [01]", previewCswi.Value2); - Assert.Equal("2026-09-13 14:10:19.456", previewCswi.Value2TimestampText); + Assert.Equal(expectedV2, previewCswi.Value2TimestampText); Assert.Equal("COMPLETE", previewCswi.Result); } finally diff --git a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs index cf7de5c71..99b8d871c 100644 --- a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs +++ b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs @@ -37,7 +37,7 @@ public void Capture_CopiesSelectedCanonicalRowsInCurrentOrderAndSparseEvidence() Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); Assert.NotEqual("—", snapshot.Rows[0].Value2TimestampText); Assert.Equal("PASS", snapshot.Rows[0].Result); - Assert.Equal("1/2 complete", snapshot.ProgressText); + Assert.Equal("Evidence complete: 1 / 2 signals", snapshot.ProgressText); } [Fact] @@ -207,4 +207,4 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException( $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } -} \ No newline at end of file +} diff --git a/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs b/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs index a8121dde2..b777d3b74 100644 --- a/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs @@ -182,7 +182,10 @@ public void P4E_DigitalAnalogPositionAndTapEvidenceKeepMillisecondTimestamp(stri var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); Assert.Equal(rawValue, snapshot.Rows[0].Value1); - Assert.Equal("2026-09-12 06:46:31.958", snapshot.Rows[0].Value1TimestampText); + var expectedLocal = DateTimeOffset.Parse("2026-09-12T06:46:31.958+07:00") + .ToLocalTime() + .ToString("dd/MM/yyyy HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); + Assert.Equal(expectedLocal, snapshot.Rows[0].Value1TimestampText); } [Fact] @@ -288,4 +291,4 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException( $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } -} \ No newline at end of file +}