From 8f60321ae7413bd5eb7f447f08973b5a59ef98d1 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 17:09:41 +0700 Subject: [PATCH 01/11] Add durable IO FAT project persistence and portable handover packages --- .../IoTestProjectPersistenceService.cs | 764 ++++++++++++++++++ 1 file changed, 764 insertions(+) create mode 100644 Services/IoTesting/IoTestProjectPersistenceService.cs diff --git a/Services/IoTesting/IoTestProjectPersistenceService.cs b/Services/IoTesting/IoTestProjectPersistenceService.cs new file mode 100644 index 0000000..7daae63 --- /dev/null +++ b/Services/IoTesting/IoTestProjectPersistenceService.cs @@ -0,0 +1,764 @@ +using System.ComponentModel; +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record IoTestWorkspaceOpenResult( + IoTestProject Project, + IoTestWorkspacePersistence Workspace, + bool RestoredProgress, + IReadOnlyList Warnings); + +public sealed class IoTestWorkspacePersistence : ObservableObject, IDisposable +{ + public const string PackageExtension = ".arsas-iofat"; + public const string SnapshotVersion = "ARSAS-IOFAT-SNAPSHOT-1.0"; + public const string PackageVersion = "ARSAS-IOFAT-PACKAGE-1.0"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + private readonly object _saveSync = new(); + private readonly Timer _saveTimer; + private readonly IoTestSessionController _session; + private bool _disposed; + private string _statusText = "Progress storage is ready"; + private DateTimeOffset? _lastSavedAtUtc; + private string _lastExportPath = string.Empty; + + private IoTestWorkspacePersistence( + IoTestProject project, + IoTestSessionController session, + string localDirectory, + string sourceWorkbookPath, + string evidenceProjectDirectory) + { + Project = project; + _session = session; + LocalDirectory = localDirectory; + SnapshotPath = Path.Combine(localDirectory, "project.snapshot.json"); + SourceWorkbookPath = sourceWorkbookPath; + EvidenceProjectDirectory = evidenceProjectDirectory; + _saveTimer = new Timer(_ => SaveFromTimer(), null, Timeout.Infinite, Timeout.Infinite); + Subscribe(); + } + + public IoTestProject Project { get; } + public string LocalDirectory { get; } + public string SnapshotPath { get; } + public string SourceWorkbookPath { get; } + public string EvidenceProjectDirectory { get; } + public string StatusText { get => _statusText; private set => Set(ref _statusText, value ?? string.Empty); } + public DateTimeOffset? LastSavedAtUtc { get => _lastSavedAtUtc; private set { if (Set(ref _lastSavedAtUtc, value)) Raise(nameof(LastSavedText)); } } + public string LastSavedText => LastSavedAtUtc == null + ? "Not saved yet" + : $"Saved locally {LastSavedAtUtc.Value.ToLocalTime():yyyy-MM-dd HH:mm:ss}"; + public string LastExportPath { get => _lastExportPath; private set => Set(ref _lastExportPath, value ?? string.Empty); } + public bool CanExport => !_session.IsSessionActive; + + public static async Task OpenWorkbookAsync( + IoTestProject importedProject, + IoTestSessionController session, + string workbookPath, + string localProjectsRoot, + string evidenceRoot, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(importedProject); + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(workbookPath); + cancellationToken.ThrowIfCancellationRequested(); + + var localDirectory = ProjectDirectory(localProjectsRoot, importedProject); + Directory.CreateDirectory(localDirectory); + var localSourceDirectory = Path.Combine(localDirectory, "source"); + Directory.CreateDirectory(localSourceDirectory); + var localWorkbookPath = Path.Combine(localSourceDirectory, SafeFileName(importedProject.SourceWorkbookName, "source.xlsx")); + await CopyFileAtomicAsync(workbookPath, localWorkbookPath, cancellationToken).ConfigureAwait(false); + + var snapshotPath = Path.Combine(localDirectory, "project.snapshot.json"); + var project = importedProject; + var restored = false; + var warnings = new List(); + if (File.Exists(snapshotPath)) + { + try + { + var candidate = await LoadSnapshotAsync(snapshotPath, cancellationToken).ConfigureAwait(false); + if (candidate.ProjectId.Equals(importedProject.ProjectId, StringComparison.OrdinalIgnoreCase) && + candidate.SchemaVersion.Equals(importedProject.SchemaVersion, StringComparison.OrdinalIgnoreCase) && + candidate.SourceWorkbookSha256.Equals(importedProject.SourceWorkbookSha256, StringComparison.OrdinalIgnoreCase)) + { + project = candidate; + restored = true; + } + else + { + warnings.Add("A local snapshot existed but did not match the current workbook identity, so it was not restored."); + } + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException) + { + warnings.Add($"The local snapshot could not be restored and a fresh workspace was opened: {ex.Message}"); + } + } + + project.InitializeRuntimeNotifications(); + var evidenceDirectory = Path.Combine(evidenceRoot, SanitizePathPart(project.ProjectId)); + var workspace = new IoTestWorkspacePersistence(project, session, localDirectory, localWorkbookPath, evidenceDirectory); + workspace.SaveNow(); + return new IoTestWorkspaceOpenResult(project, workspace, restored, warnings); + } + + public static async Task ImportPackageAsync( + string packagePath, + IoTestSessionControllerFactory sessionFactory, + string localProjectsRoot, + string evidenceRoot, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packagePath); + ArgumentNullException.ThrowIfNull(sessionFactory); + if (!File.Exists(packagePath)) + throw new FileNotFoundException("The IO FAT handover package was not found.", packagePath); + if (new FileInfo(packagePath).Length > 500L * 1024 * 1024) + throw new InvalidDataException("The IO FAT handover package exceeds the 500 MB safety limit."); + + using var archive = ZipFile.OpenRead(packagePath); + if (archive.Entries.Count > 10_000) + throw new InvalidDataException("The IO FAT handover package contains too many entries."); + + var manifestEntry = RequiredEntry(archive, "manifest.json"); + var manifestBytes = await ReadEntryAsync(manifestEntry, 5 * 1024 * 1024, cancellationToken).ConfigureAwait(false); + var manifest = JsonSerializer.Deserialize(manifestBytes, JsonOptions) + ?? throw new InvalidDataException("The IO FAT package manifest is invalid."); + if (!manifest.PackageVersion.Equals(PackageVersion, StringComparison.Ordinal)) + throw new InvalidDataException($"Unsupported IO FAT package version '{manifest.PackageVersion}'."); + + var snapshotEntry = RequiredEntry(archive, manifest.SnapshotEntry); + var snapshotBytes = await ReadEntryAsync(snapshotEntry, 100 * 1024 * 1024, cancellationToken).ConfigureAwait(false); + VerifyHash(snapshotBytes, manifest.SnapshotSha256, "project snapshot"); + var snapshot = JsonSerializer.Deserialize(snapshotBytes, JsonOptions) + ?? throw new InvalidDataException("The IO FAT project snapshot is invalid."); + var project = RestoreProject(snapshot); + if (!project.ProjectId.Equals(manifest.ProjectId, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The package project identity does not match its snapshot."); + + var localDirectory = ProjectDirectory(localProjectsRoot, project); + Directory.CreateDirectory(localDirectory); + var sourceDirectory = Path.Combine(localDirectory, "source"); + Directory.CreateDirectory(sourceDirectory); + var sourceEntry = RequiredEntry(archive, manifest.SourceWorkbookEntry); + var sourceBytes = await ReadEntryAsync(sourceEntry, 100 * 1024 * 1024, cancellationToken).ConfigureAwait(false); + VerifyHash(sourceBytes, project.SourceWorkbookSha256, "source workbook"); + var sourcePath = Path.Combine(sourceDirectory, SafeFileName(project.SourceWorkbookName, "source.xlsx")); + await WriteFileAtomicAsync(sourcePath, sourceBytes, cancellationToken).ConfigureAwait(false); + + var evidenceDirectory = Path.Combine(evidenceRoot, SanitizePathPart(project.ProjectId)); + Directory.CreateDirectory(evidenceDirectory); + var warnings = new List(); + foreach (var evidence in manifest.EvidenceFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = RequiredEntry(archive, evidence.Entry); + var bytes = await ReadEntryAsync(entry, 100 * 1024 * 1024, cancellationToken).ConfigureAwait(false); + VerifyHash(bytes, evidence.Sha256, $"evidence '{evidence.Entry}'"); + var destination = Path.Combine(evidenceDirectory, SafeFileName(Path.GetFileName(evidence.Entry), "evidence.jsonl")); + if (File.Exists(destination)) + { + var existingHash = HashFile(destination); + if (existingHash.Equals(evidence.Sha256, StringComparison.OrdinalIgnoreCase)) + continue; + destination = Path.Combine( + evidenceDirectory, + $"imported_{DateTime.UtcNow:yyyyMMddHHmmssfff}_{SafeFileName(Path.GetFileName(evidence.Entry), "evidence.jsonl")}"); + warnings.Add($"Evidence filename collision was preserved as '{Path.GetFileName(destination)}'."); + } + await WriteFileAtomicAsync(destination, bytes, cancellationToken).ConfigureAwait(false); + var verification = IoTestEvidenceJournal.Verify(destination); + if (!verification.IsValid) + throw new InvalidDataException($"Imported evidence failed hash-chain verification: {verification.Error}"); + } + + await WriteFileAtomicAsync(Path.Combine(localDirectory, "project.snapshot.json"), snapshotBytes, cancellationToken).ConfigureAwait(false); + project.InitializeRuntimeNotifications(); + var session = sessionFactory(project, evidenceRoot); + var workspace = new IoTestWorkspacePersistence(project, session, localDirectory, sourcePath, evidenceDirectory); + workspace.StatusText = $"Handover package imported from {Path.GetFileName(packagePath)}"; + workspace.LastSavedAtUtc = snapshot.SavedAtUtc; + workspace.SaveNow(); + return new IoTestWorkspaceOpenResult(project, workspace, true, warnings); + } + + public void ScheduleSave() + { + if (_disposed) + return; + StatusText = "Progress changed · saving shortly…"; + _saveTimer.Change(650, Timeout.Infinite); + } + + public void SaveNow() + { + ThrowIfDisposed(); + lock (_saveSync) + { + Directory.CreateDirectory(LocalDirectory); + var snapshot = CaptureSnapshot(Project); + var bytes = JsonSerializer.SerializeToUtf8Bytes(snapshot, JsonOptions); + WriteFileAtomic(SnapshotPath, bytes); + LastSavedAtUtc = snapshot.SavedAtUtc; + StatusText = $"Progress saved locally · {Path.GetFileName(SnapshotPath)}"; + } + } + + public async Task ExportPackageAsync(string destinationPath, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + if (_session.IsSessionActive) + throw new InvalidOperationException("Stop the active FAT session before exporting a handover package so every evidence journal is sealed."); + + SaveNow(); + var snapshotBytes = await File.ReadAllBytesAsync(SnapshotPath, cancellationToken).ConfigureAwait(false); + var sourceBytes = await File.ReadAllBytesAsync(SourceWorkbookPath, cancellationToken).ConfigureAwait(false); + VerifyHash(sourceBytes, Project.SourceWorkbookSha256, "local source workbook"); + + var evidenceFiles = new List(); + if (Directory.Exists(EvidenceProjectDirectory)) + { + foreach (var path in Directory.EnumerateFiles(EvidenceProjectDirectory, "*.evidence.jsonl", SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + var verification = IoTestEvidenceJournal.Verify(path); + if (!verification.IsValid) + throw new InvalidDataException($"Evidence '{Path.GetFileName(path)}' failed verification: {verification.Error}"); + evidenceFiles.Add(new IoTestPackageEvidence( + $"evidence/{Path.GetFileName(path)}", + HashFile(path), + verification.RecordCount, + verification.LastHash)); + } + } + + var snapshotHash = HashBytes(snapshotBytes); + var reportBytes = Encoding.UTF8.GetBytes(BuildPrintableReport(Project)); + var manifest = new IoTestPackageManifest( + PackageVersion, + DateTimeOffset.UtcNow, + Project.ProjectId, + Project.ProjectName, + Project.SchemaVersion, + "project.snapshot.json", + snapshotHash, + $"source/{SafeFileName(Project.SourceWorkbookName, "source.xlsx")}", + Project.SourceWorkbookSha256, + "report/IO-FAT-Report.html", + evidenceFiles); + + var fullDestination = destinationPath.EndsWith(PackageExtension, StringComparison.OrdinalIgnoreCase) + ? destinationPath + : destinationPath + PackageExtension; + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(fullDestination))!); + var temporary = fullDestination + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + using (var archive = ZipFile.Open(temporary, ZipArchiveMode.Create)) + { + await WriteEntryAsync(archive, "manifest.json", JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions), cancellationToken).ConfigureAwait(false); + await WriteEntryAsync(archive, manifest.SnapshotEntry, snapshotBytes, cancellationToken).ConfigureAwait(false); + await WriteEntryAsync(archive, manifest.SourceWorkbookEntry, sourceBytes, cancellationToken).ConfigureAwait(false); + await WriteEntryAsync(archive, manifest.ReportEntry, reportBytes, cancellationToken).ConfigureAwait(false); + await WriteEntryAsync(archive, "README.txt", Encoding.UTF8.GetBytes(BuildPackageReadme()), cancellationToken).ConfigureAwait(false); + foreach (var evidence in evidenceFiles) + { + var path = Path.Combine(EvidenceProjectDirectory, Path.GetFileName(evidence.Entry)); + await WriteEntryAsync(archive, evidence.Entry, await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + } + } + File.Move(temporary, fullDestination, true); + LastExportPath = fullDestination; + StatusText = $"Portable handover exported · {Path.GetFileName(fullDestination)}"; + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + public void Dispose() + { + if (_disposed) + return; + try + { + SaveNow(); + } + catch + { + // Window shutdown must not be blocked by a secondary autosave failure. + } + _disposed = true; + _saveTimer.Dispose(); + Unsubscribe(); + } + + private void Subscribe() + { + _session.PropertyChanged += Changed; + foreach (var ied in Project.Ieds) + { + ied.PropertyChanged += Changed; + foreach (var point in ied.TestPoints) + { + point.PropertyChanged += Changed; + point.Runtime.PropertyChanged += Changed; + } + } + } + + private void Unsubscribe() + { + _session.PropertyChanged -= Changed; + foreach (var ied in Project.Ieds) + { + ied.PropertyChanged -= Changed; + foreach (var point in ied.TestPoints) + { + point.PropertyChanged -= Changed; + point.Runtime.PropertyChanged -= Changed; + } + } + } + + private void Changed(object? sender, PropertyChangedEventArgs e) => ScheduleSave(); + + private void SaveFromTimer() + { + try + { + SaveNow(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + StatusText = $"Autosave failed: {ex.Message}"; + } + } + + private static IoTestProjectSnapshot CaptureSnapshot(IoTestProject project) => new( + SnapshotVersion, + DateTimeOffset.UtcNow, + new IoTestProjectData( + project.ProjectId, + project.SchemaVersion, + project.ProjectName, + project.SourceWorkbookName, + project.SourceWorkbookSha256, + project.ImportedAt, + project.Ieds.Select(ied => new IoTestIedData( + ied.IedName, + ied.IpAddress, + ied.IedRole, + ied.Location, + ied.VoltageLevel, + ied.Switchgear, + ied.TestPoints.Select(point => new IoTestPointData( + point.TestPointId, + point.IedName, + point.IpAddress, + point.SignalName, + point.ObjectReference, + point.FunctionalConstraint, + point.ExpectedOnText, + point.ExpectedOffText, + point.ExpectedOnRaw, + point.ExpectedOffRaw, + point.DataType, + point.SignalAddress, + point.DataSetName, + point.LogicalDevice, + point.LogicalNode, + point.DataObject, + point.DataAttribute, + point.SourceSheet, + point.SourceRow, + point.TestEnabled, + point.ImportReady, + point.BindingStatus, + point.BindingEvidence, + new IoTestRuntimeData( + point.Runtime.State, + point.Runtime.LastObservedState, + point.Runtime.LastSequence, + point.Runtime.ConnectionGeneration, + point.Runtime.OnEvidence, + point.Runtime.OffEvidence, + point.Runtime.StatusReason, + point.Runtime.Attempt, + point.Runtime.CurrentValue, + point.Runtime.CurrentQuality, + point.Runtime.CurrentSource))).ToList())).ToList())); + + private static async Task LoadSnapshotAsync(string path, CancellationToken cancellationToken) + { + var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); + var snapshot = JsonSerializer.Deserialize(bytes, JsonOptions) + ?? throw new InvalidDataException("Local IO FAT snapshot is invalid."); + return RestoreProject(snapshot); + } + + private static IoTestProject RestoreProject(IoTestProjectSnapshot snapshot) + { + if (!snapshot.SnapshotVersion.Equals(SnapshotVersion, StringComparison.Ordinal)) + throw new InvalidDataException($"Unsupported IO FAT snapshot version '{snapshot.SnapshotVersion}'."); + + var project = new IoTestProject + { + ProjectId = snapshot.Project.ProjectId, + SchemaVersion = snapshot.Project.SchemaVersion, + ProjectName = snapshot.Project.ProjectName, + SourceWorkbookName = snapshot.Project.SourceWorkbookName, + SourceWorkbookSha256 = snapshot.Project.SourceWorkbookSha256, + ImportedAt = snapshot.Project.ImportedAt, + Ieds = snapshot.Project.Ieds.Select(ied => new IoTestIedPlan + { + IedName = ied.IedName, + IpAddress = ied.IpAddress, + IedRole = ied.IedRole, + Location = ied.Location, + VoltageLevel = ied.VoltageLevel, + Switchgear = ied.Switchgear, + TestPoints = ied.TestPoints.Select(RestorePoint).ToList() + }).ToList() + }; + project.InitializeRuntimeNotifications(); + return project; + } + + private static IoTestPointPlan RestorePoint(IoTestPointData data) + { + var point = new IoTestPointPlan + { + TestPointId = data.TestPointId, + IedName = data.IedName, + IpAddress = data.IpAddress, + SignalName = data.SignalName, + ObjectReference = data.ObjectReference, + FunctionalConstraint = data.FunctionalConstraint, + ExpectedOnText = data.ExpectedOnText, + ExpectedOffText = data.ExpectedOffText, + ExpectedOnRaw = data.ExpectedOnRaw, + ExpectedOffRaw = data.ExpectedOffRaw, + DataType = data.DataType, + SignalAddress = data.SignalAddress, + DataSetName = data.DataSetName, + LogicalDevice = data.LogicalDevice, + LogicalNode = data.LogicalNode, + DataObject = data.DataObject, + DataAttribute = data.DataAttribute, + SourceSheet = data.SourceSheet, + SourceRow = data.SourceRow, + TestEnabled = data.TestEnabled, + ImportReady = data.ImportReady, + BindingStatus = data.BindingStatus, + BindingEvidence = data.BindingEvidence + }; + + var runtime = point.Runtime; + runtime.Attempt = data.Runtime.Attempt; + runtime.OnEvidence = data.Runtime.OnEvidence; + runtime.OffEvidence = data.Runtime.OffEvidence; + runtime.CurrentValue = "-"; + runtime.CurrentQuality = "Unknown"; + runtime.CurrentSource = "Restored · live baseline required"; + runtime.LastObservedState = null; + runtime.LastSequence = -1; + runtime.ConnectionGeneration = -1; + + if (data.Runtime.State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed) + { + runtime.State = data.Runtime.State; + runtime.StatusReason = data.Runtime.StatusReason; + } + else if (data.Runtime.OnEvidence != null && data.Runtime.OffEvidence == null) + { + runtime.State = IoTestPointState.Review; + runtime.StatusReason = "Progress was restored after the live session ended; OFF continuity after saved ON evidence cannot be proven."; + } + else + { + runtime.State = IoTestPointState.NotStarted; + runtime.StatusReason = "Progress restored; a new good-quality live baseline is required before continuing."; + } + return point; + } + + private static string BuildPrintableReport(IoTestProject project) + { + var totalPassed = project.Ieds.Sum(ied => ied.PassedCount); + var totalReview = project.Ieds.Sum(ied => ied.ReviewCount); + var builder = new StringBuilder(); + builder.Append("") + .Append(Html(project.ProjectName)).Append(" - IO FAT Report"); + builder.Append("

ARSAS IO List FAT Evidence Report

Project: ").Append(Html(project.ProjectName)) + .Append("
Project ID: ").Append(Html(project.ProjectId)) + .Append("
Source workbook: ").Append(Html(project.SourceWorkbookName)) + .Append("
Workbook SHA-256: ").Append(Html(project.SourceWorkbookSha256)) + .Append("
Generated: ").Append(Html(DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"))).Append("
"); + builder.Append("
IED: ").Append(project.Ieds.Count) + .Append("Signals: ").Append(project.SignalCount) + .Append("PASS: ").Append(totalPassed) + .Append("Review: ").Append(totalReview).Append("
"); + + foreach (var ied in project.Ieds) + { + builder.Append("

").Append(Html(ied.IedName)).Append(" · ").Append(Html(ied.IpAddress)).Append("

") + .Append("

").Append(Html(ied.IedRole)).Append(" · ").Append(Html(ied.Location)).Append(" · ").Append(Html(ied.VoltageLevel)).Append("

") + .Append(""); + var index = 0; + foreach (var point in ied.TestPoints) + { + index++; + builder.Append(""); + } + builder.Append("
#SignalIEC 61850 referenceExpected ON/OFFON evidenceOFF evidenceResultReason
").Append(index).Append("").Append(Html(point.SignalName)).Append("").Append(Html(point.ObjectReference)).Append("") + .Append(Html($"{point.ExpectedOnText} ({point.ExpectedOnRaw}) / {point.ExpectedOffText} ({point.ExpectedOffRaw})")) + .Append("").Append(EvidenceHtml(point.Runtime.OnEvidence)).Append("").Append(EvidenceHtml(point.Runtime.OffEvidence)).Append("").Append(Html(point.Runtime.State.ToString())).Append("").Append(Html(point.Runtime.StatusReason)).Append("
"); + } + builder.Append("

Open this file in a browser and choose Print → Save as PDF.

"); + return builder.ToString(); + } + + private static string EvidenceHtml(IoTestTransitionEvidence? evidence) + { + if (evidence == null) + return ""; + var iedTime = evidence.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz") ?? "not supplied"; + return Html($"IED {iedTime}\nARSAS {evidence.CapturedAt:yyyy-MM-dd HH:mm:ss.fff zzz}\n{evidence.RawValue} · {evidence.Quality} · {evidence.AcquisitionSource}\n{evidence.Verdict}") + .Replace("\n", "
", StringComparison.Ordinal); + } + + private static string ResultClass(IoTestPointState state) => state switch + { + IoTestPointState.Passed => "pass", + IoTestPointState.Review => "review", + IoTestPointState.Failed => "failed", + _ => "pending" + }; + + private static string BuildPackageReadme() => + "ARSAS IO FAT portable handover package\r\n\r\n" + + "To continue testing: open this .arsas-iofat file from the FAT / IO List Testing card in ARSAS.\r\n" + + "To print or create PDF without ARSAS: extract the package and open report/IO-FAT-Report.html in a browser, then Print -> Save as PDF.\r\n" + + "The package contains the project snapshot, source workbook, verified evidence journals, and a printable report.\r\n"; + + private static ZipArchiveEntry RequiredEntry(ZipArchive archive, string name) + { + if (string.IsNullOrWhiteSpace(name) || name.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(name)) + throw new InvalidDataException("The package contains an unsafe entry path."); + return archive.GetEntry(name.Replace('\\', '/')) + ?? throw new InvalidDataException($"The package entry '{name}' is missing."); + } + + private static async Task ReadEntryAsync(ZipArchiveEntry entry, long maximumBytes, CancellationToken cancellationToken) + { + if (entry.Length > maximumBytes) + throw new InvalidDataException($"Package entry '{entry.FullName}' exceeds its safety limit."); + await using var source = entry.Open(); + using var memory = new MemoryStream((int)Math.Min(entry.Length, int.MaxValue)); + await source.CopyToAsync(memory, cancellationToken).ConfigureAwait(false); + if (memory.Length > maximumBytes) + throw new InvalidDataException($"Package entry '{entry.FullName}' exceeds its safety limit."); + return memory.ToArray(); + } + + private static async Task WriteEntryAsync(ZipArchive archive, string entryName, byte[] bytes, CancellationToken cancellationToken) + { + var entry = archive.CreateEntry(entryName.Replace('\\', '/'), CompressionLevel.Optimal); + await using var destination = entry.Open(); + await destination.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + } + + private static string ProjectDirectory(string root, IoTestProject project) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + var hash = string.IsNullOrWhiteSpace(project.SourceWorkbookSha256) + ? "nohash" + : project.SourceWorkbookSha256[..Math.Min(12, project.SourceWorkbookSha256.Length)]; + return Path.Combine(root, $"{SanitizePathPart(project.ProjectId)}_{hash}"); + } + + private static string SafeFileName(string? value, string fallback) + { + var name = Path.GetFileName(string.IsNullOrWhiteSpace(value) ? fallback : value.Trim()); + return SanitizePathPart(name); + } + + private static string SanitizePathPart(string? value) + { + var text = string.IsNullOrWhiteSpace(value) ? "IO-TEST" : value.Trim(); + var invalid = Path.GetInvalidFileNameChars().ToHashSet(); + var sanitized = new string(text.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); + return sanitized.Length == 0 ? "IO-TEST" : sanitized; + } + + private static async Task CopyFileAtomicAsync(string source, string destination, CancellationToken cancellationToken) + { + var bytes = await File.ReadAllBytesAsync(source, cancellationToken).ConfigureAwait(false); + await WriteFileAtomicAsync(destination, bytes, cancellationToken).ConfigureAwait(false); + } + + private static async Task WriteFileAtomicAsync(string destination, byte[] bytes, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var temporary = destination + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + await File.WriteAllBytesAsync(temporary, bytes, cancellationToken).ConfigureAwait(false); + File.Move(temporary, destination, true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + private static void WriteFileAtomic(string destination, byte[] bytes) + { + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var temporary = destination + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + File.Move(temporary, destination, true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + private static void VerifyHash(byte[] bytes, string expected, string label) + { + var actual = HashBytes(bytes); + if (!actual.Equals(expected, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"The {label} SHA-256 does not match the package manifest."); + } + + private static string HashBytes(byte[] bytes) + => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private static string HashFile(string path) + => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); + + private static string Html(string? value) => WebUtility.HtmlEncode(value ?? string.Empty); + + private void RaiseExportState() + { + Raise(nameof(CanExport)); + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + + public delegate IoTestSessionController IoTestSessionControllerFactory(IoTestProject project, string evidenceRoot); + + private sealed record IoTestProjectSnapshot( + string SnapshotVersion, + DateTimeOffset SavedAtUtc, + IoTestProjectData Project); + + private sealed record IoTestProjectData( + string ProjectId, + string SchemaVersion, + string ProjectName, + string SourceWorkbookName, + string SourceWorkbookSha256, + DateTimeOffset ImportedAt, + List Ieds); + + private sealed record IoTestIedData( + string IedName, + string IpAddress, + string IedRole, + string Location, + string VoltageLevel, + string Switchgear, + List TestPoints); + + private sealed record IoTestPointData( + string TestPointId, + string IedName, + string IpAddress, + string SignalName, + string ObjectReference, + string FunctionalConstraint, + string ExpectedOnText, + string ExpectedOffText, + int ExpectedOnRaw, + int ExpectedOffRaw, + string DataType, + string SignalAddress, + string DataSetName, + string LogicalDevice, + string LogicalNode, + string DataObject, + string DataAttribute, + string SourceSheet, + int SourceRow, + bool TestEnabled, + bool ImportReady, + string BindingStatus, + string BindingEvidence, + IoTestRuntimeData Runtime); + + private sealed record IoTestRuntimeData( + IoTestPointState State, + bool? LastObservedState, + long LastSequence, + long ConnectionGeneration, + IoTestTransitionEvidence? OnEvidence, + IoTestTransitionEvidence? OffEvidence, + string StatusReason, + int Attempt, + string CurrentValue, + string CurrentQuality, + string CurrentSource); + + private sealed record IoTestPackageManifest( + string PackageVersion, + DateTimeOffset CreatedAtUtc, + string ProjectId, + string ProjectName, + string SchemaVersion, + string SnapshotEntry, + string SnapshotSha256, + string SourceWorkbookEntry, + string SourceWorkbookSha256, + string ReportEntry, + List EvidenceFiles); + + private sealed record IoTestPackageEvidence( + string Entry, + string Sha256, + long RecordCount, + string LastHash); +} From 045447d2924ebbfbc12e2712021179b88a5b4342 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 17:11:46 +0700 Subject: [PATCH 02/11] Restore local IO FAT progress before creating live sessions --- .../IoTestWorkspaceBootstrapService.cs | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 Services/IoTesting/IoTestWorkspaceBootstrapService.cs diff --git a/Services/IoTesting/IoTestWorkspaceBootstrapService.cs b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs new file mode 100644 index 0000000..7c0c311 --- /dev/null +++ b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs @@ -0,0 +1,227 @@ +using System.Text.Json; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record IoTestWorkspaceLaunchResult( + IoTestProject Project, + IoTestSessionController Session, + IoTestWorkspacePersistence Workspace, + bool RestoredProgress, + IReadOnlyList Warnings); + +public static class IoTestWorkspaceBootstrapService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static async Task OpenWorkbookAsync( + IoTestProject importedProject, + string workbookPath, + string localProjectsRoot, + string evidenceRoot, + Func sessionFactory, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(importedProject); + ArgumentNullException.ThrowIfNull(sessionFactory); + + var localDirectory = ProjectDirectory(localProjectsRoot, importedProject); + var snapshotPath = Path.Combine(localDirectory, "project.snapshot.json"); + var backupPath = snapshotPath + ".bootstrap-" + Guid.NewGuid().ToString("N"); + var warnings = new List(); + var restored = false; + var movedSnapshot = false; + + try + { + if (File.Exists(snapshotPath)) + { + try + { + ApplySnapshotProgress(importedProject, snapshotPath); + restored = true; + Directory.CreateDirectory(localDirectory); + File.Move(snapshotPath, backupPath, true); + movedSnapshot = true; + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException) + { + warnings.Add($"Local progress could not be restored; a fresh FAT workspace was opened: {ex.Message}"); + } + } + + var session = sessionFactory(importedProject, evidenceRoot); + try + { + var opened = await IoTestWorkspacePersistence.OpenWorkbookAsync( + importedProject, + session, + workbookPath, + localProjectsRoot, + evidenceRoot, + cancellationToken).ConfigureAwait(false); + warnings.AddRange(opened.Warnings); + if (movedSnapshot && File.Exists(backupPath)) + File.Delete(backupPath); + return new IoTestWorkspaceLaunchResult( + opened.Project, + session, + opened.Workspace, + restored || opened.RestoredProgress, + warnings); + } + catch + { + session.Dispose(); + throw; + } + } + catch + { + if (movedSnapshot && File.Exists(backupPath) && !File.Exists(snapshotPath)) + File.Move(backupPath, snapshotPath, true); + throw; + } + } + + public static async Task OpenPackageAsync( + string packagePath, + string localProjectsRoot, + string evidenceRoot, + Func sessionFactory, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(sessionFactory); + IoTestSessionController? createdSession = null; + var opened = await IoTestWorkspacePersistence.ImportPackageAsync( + packagePath, + (project, root) => createdSession = sessionFactory(project, root), + localProjectsRoot, + evidenceRoot, + cancellationToken).ConfigureAwait(false); + return new IoTestWorkspaceLaunchResult( + opened.Project, + createdSession ?? throw new InvalidOperationException("The IO FAT package did not create a session controller."), + opened.Workspace, + true, + opened.Warnings); + } + + private static void ApplySnapshotProgress(IoTestProject project, string snapshotPath) + { + using var document = JsonDocument.Parse(File.ReadAllBytes(snapshotPath)); + var root = document.RootElement; + var version = RequiredString(root, "snapshotVersion"); + if (!version.Equals(IoTestWorkspacePersistence.SnapshotVersion, StringComparison.Ordinal)) + throw new InvalidDataException($"Unsupported local snapshot version '{version}'."); + + var savedProject = RequiredObject(root, "project"); + if (!RequiredString(savedProject, "projectId").Equals(project.ProjectId, StringComparison.OrdinalIgnoreCase) || + !RequiredString(savedProject, "schemaVersion").Equals(project.SchemaVersion, StringComparison.OrdinalIgnoreCase) || + !RequiredString(savedProject, "sourceWorkbookSha256").Equals(project.SourceWorkbookSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("The local snapshot belongs to a different workbook or schema."); + } + + var savedPoints = RequiredArray(savedProject, "ieds") + .EnumerateArray() + .SelectMany(ied => RequiredArray(ied, "testPoints").EnumerateArray()) + .ToDictionary(point => RequiredString(point, "testPointId"), StringComparer.OrdinalIgnoreCase); + + foreach (var point in project.Ieds.SelectMany(ied => ied.TestPoints)) + { + if (!savedPoints.TryGetValue(point.TestPointId, out var saved)) + continue; + + if (saved.TryGetProperty("testEnabled", out var enabled) && enabled.ValueKind is JsonValueKind.True or JsonValueKind.False) + point.TestEnabled = enabled.GetBoolean(); + if (!saved.TryGetProperty("runtime", out var runtime) || runtime.ValueKind != JsonValueKind.Object) + continue; + + point.Runtime.Attempt = OptionalInt(runtime, "attempt", 0); + point.Runtime.OnEvidence = OptionalEvidence(runtime, "onEvidence"); + point.Runtime.OffEvidence = OptionalEvidence(runtime, "offEvidence"); + point.Runtime.LastObservedState = null; + point.Runtime.LastSequence = -1; + point.Runtime.ConnectionGeneration = -1; + point.Runtime.CurrentValue = "-"; + point.Runtime.CurrentQuality = "Unknown"; + point.Runtime.CurrentSource = "Restored · live baseline required"; + + var savedStateText = OptionalString(runtime, "state", IoTestPointState.NotStarted.ToString()); + _ = Enum.TryParse(savedStateText, ignoreCase: true, out var savedState); + if (savedState is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed) + { + point.Runtime.State = savedState; + point.Runtime.StatusReason = OptionalString(runtime, "statusReason", "Restored completed result"); + } + else if (point.Runtime.OnEvidence != null && point.Runtime.OffEvidence == null) + { + point.Runtime.State = IoTestPointState.Review; + point.Runtime.StatusReason = "Progress was restored after the live session ended; OFF continuity after saved ON evidence cannot be proven."; + } + else + { + point.Runtime.State = IoTestPointState.NotStarted; + point.Runtime.StatusReason = "Progress restored; a new good-quality live baseline is required before continuing."; + } + } + project.InitializeRuntimeNotifications(); + } + + private static IoTestTransitionEvidence? OptionalEvidence(JsonElement runtime, string property) + { + if (!runtime.TryGetProperty(property, out var element) || element.ValueKind == JsonValueKind.Null) + return null; + return element.Deserialize(JsonOptions); + } + + private static JsonElement RequiredObject(JsonElement parent, string property) + { + if (!parent.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Object) + throw new InvalidDataException($"Snapshot property '{property}' is missing or invalid."); + return value; + } + + private static JsonElement RequiredArray(JsonElement parent, string property) + { + if (!parent.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array) + throw new InvalidDataException($"Snapshot property '{property}' is missing or invalid."); + return value; + } + + private static string RequiredString(JsonElement parent, string property) + { + if (!parent.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.String) + throw new InvalidDataException($"Snapshot property '{property}' is missing or invalid."); + return value.GetString() ?? string.Empty; + } + + private static string OptionalString(JsonElement parent, string property, string fallback) + => parent.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? fallback + : fallback; + + private static int OptionalInt(JsonElement parent, string property, int fallback) + => parent.TryGetProperty(property, out var value) && value.TryGetInt32(out var number) + ? number + : fallback; + + private static string ProjectDirectory(string root, IoTestProject project) + { + var hash = string.IsNullOrWhiteSpace(project.SourceWorkbookSha256) + ? "nohash" + : project.SourceWorkbookSha256[..Math.Min(12, project.SourceWorkbookSha256.Length)]; + return Path.Combine(root, $"{Sanitize(project.ProjectId)}_{hash}"); + } + + private static string Sanitize(string value) + { + var invalid = Path.GetInvalidFileNameChars().ToHashSet(); + var result = new string((value ?? "IO-TEST").Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); + return result.Length == 0 ? "IO-TEST" : result; + } +} From b3cb7bf91723ba99b9465333da1e9a6ae5ba86b5 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 17:12:53 +0700 Subject: [PATCH 03/11] Open persisted IO FAT workspaces and portable handover packages --- MainWindow.IoTesting.cs | 164 ++++++++++++++++++++++++++++++---------- 1 file changed, 122 insertions(+), 42 deletions(-) diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs index ec40d5b..9323922 100644 --- a/MainWindow.IoTesting.cs +++ b/MainWindow.IoTesting.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Text.Json; using System.Windows; using System.Windows.Controls; using System.Windows.Data; @@ -139,7 +140,7 @@ private Border CreateIoListTestingCard() }); content.Children.Add(new TextBlock { - Text = "Run FAT from an approved IO List", + Text = "Run or continue FAT from an IO List", FontSize = 24, FontWeight = FontWeights.SemiBold, Foreground = TryFindResource("Ink") as Brush, @@ -147,7 +148,7 @@ private Border CreateIoListTestingCard() }); content.Children.Add(new TextBlock { - Text = "Import the ARSAS Excel template, choose an IED, and capture ordered ON and OFF timestamps automatically in a dedicated evidence workspace.", + Text = "Import the ARSAS Excel template for a new project, or open a portable handover package to continue saved progress from another laptop.", TextWrapping = TextWrapping.Wrap, FontSize = 13.4, Foreground = TryFindResource("Muted") as Brush, @@ -159,10 +160,17 @@ private Border CreateIoListTestingCard() "PrimaryButton", OpenIoListTesting_Click, Brushes.White, + new Thickness(0, 0, 0, 8))); + content.Children.Add(CreateLauncherButton( + "Open FAT Handover Package", + "LucideFolderOpen", + "SoftButton", + OpenIoListPackage_Click, + null, new Thickness(0, 0, 0, 10))); content.Children.Add(new TextBlock { - Text = "IED-scoped signals · read-only IEC 61850 observation · automatic FAT evidence", + Text = "Autosave · portable continuation · verified evidence · printable browser report", Style = TryFindResource("Caption") as Style, TextWrapping = TextWrapping.Wrap }); @@ -210,7 +218,8 @@ private Button CreateLauncherButton( Content = buttonContent, Padding = new Thickness(12, 8, 12, 8), Margin = margin, - VerticalAlignment = VerticalAlignment.Center + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Stretch }; button.Click += handler; return button; @@ -258,52 +267,123 @@ private async void OpenIoListTesting_Click(object sender, RoutedEventArgs e) return; } - var binding = _ioTestLiveBindingService.Bind(import.Project, Devices); - var warnings = import.AllFindings.Count(finding => - finding.Severity == IoTestImportFindingSeverity.Warning); - SetStatus( - $"IO List ready: {import.Project.Ieds.Count} IED, {import.Project.SignalCount} SDI, " + - $"{binding.SignalBoundCount} matched to the loaded workspace, {warnings} warning(s)."); - - var journalRoot = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "ARSAS", - "IO Testing Evidence"); - using var controller = new IoTestSessionController( + var launch = await IoTestWorkspaceBootstrapService.OpenWorkbookAsync( import.Project, - ResolveIoTestDevice, - action => Dispatcher.BeginInvoke(action, DispatcherPriority.Background), - journalRoot); - var window = new IoListTestingWindow(import.Project, controller) { Owner = this }; - _activeIoTestSessionController = controller; - Interlocked.Exchange(ref _ioTestObservationSequence, DateTime.UtcNow.Ticks); - _runtime.PointUpdated += Runtime_IoTestPointUpdated; - Hide(); - try - { - window.ShowDialog(); - } - finally - { - _runtime.PointUpdated -= Runtime_IoTestPointUpdated; - _activeIoTestSessionController = null; - Show(); - if (WindowState == System.Windows.WindowState.Minimized) - WindowState = System.Windows.WindowState.Normal; - Activate(); - } + dialog.FileName, + IoTestingProjectsRoot(), + IoTestingEvidenceRoot(), + CreateIoTestSession, + _applicationCancellation.Token); + var importWarnings = import.AllFindings.Count(finding => finding.Severity == IoTestImportFindingSeverity.Warning); + await ShowIoTestingWorkspaceAsync(launch, importWarnings); } catch (OperationCanceledException) { SetStatus("IO List import cancelled."); } - catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException) + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) + { + ShowIoTestingFailure(ex, "IO List import failed"); + } + } + + private async void OpenIoListPackage_Click(object sender, RoutedEventArgs e) + { + var dialog = new OpenFileDialog + { + Title = "Open ARSAS IO FAT handover package", + Filter = $"ARSAS IO FAT handover (*{IoTestWorkspacePersistence.PackageExtension})|*{IoTestWorkspacePersistence.PackageExtension}|All files (*.*)|*.*", + CheckFileExists = true, + Multiselect = false + }; + if (dialog.ShowDialog(this) != true) + return; + + SetStatus($"Opening FAT handover package {Path.GetFileName(dialog.FileName)}…"); + try + { + var launch = await IoTestWorkspaceBootstrapService.OpenPackageAsync( + dialog.FileName, + IoTestingProjectsRoot(), + IoTestingEvidenceRoot(), + CreateIoTestSession, + _applicationCancellation.Token); + await ShowIoTestingWorkspaceAsync(launch, 0); + } + catch (OperationCanceledException) + { + SetStatus("FAT handover import cancelled."); + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) + { + ShowIoTestingFailure(ex, "FAT handover import failed"); + } + } + + private Task ShowIoTestingWorkspaceAsync(IoTestWorkspaceLaunchResult launch, int importWarningCount) + { + var binding = _ioTestLiveBindingService.Bind(launch.Project, Devices); + var restoredText = launch.RestoredProgress ? "saved progress restored" : "new project"; + SetStatus( + $"IO List ready: {launch.Project.Ieds.Count} IED, {launch.Project.SignalCount} SDI, " + + $"{binding.SignalBoundCount} live-bound, {restoredText}, {importWarningCount + launch.Warnings.Count} warning(s)."); + + if (launch.Warnings.Count > 0) { - AddLog("ERROR", "IO Testing", ex.Message); - MarkDiagnosticAlert(); - SetStatus("IO List import failed. Diagnostics is marked with !."); - MessageBox.Show(this, ex.Message, "IO List import failed", MessageBoxButton.OK, MessageBoxImage.Error); + MessageBox.Show( + this, + string.Join(Environment.NewLine, launch.Warnings.Take(12).Select(warning => $"• {warning}")), + "IO FAT workspace warnings", + MessageBoxButton.OK, + MessageBoxImage.Information); } + + using var controller = launch.Session; + using var persistence = launch.Workspace; + var window = new IoListTestingWindow(launch.Project, controller, persistence) { Owner = this }; + _activeIoTestSessionController = controller; + Interlocked.Exchange(ref _ioTestObservationSequence, DateTime.UtcNow.Ticks); + _runtime.PointUpdated += Runtime_IoTestPointUpdated; + Hide(); + try + { + window.ShowDialog(); + } + finally + { + _runtime.PointUpdated -= Runtime_IoTestPointUpdated; + _activeIoTestSessionController = null; + Show(); + if (WindowState == System.Windows.WindowState.Minimized) + WindowState = System.Windows.WindowState.Normal; + Activate(); + } + return Task.CompletedTask; + } + + private IoTestSessionController CreateIoTestSession(IoTestProject project, string evidenceRoot) + => new( + project, + ResolveIoTestDevice, + action => Dispatcher.BeginInvoke(action, DispatcherPriority.Background), + evidenceRoot); + + private static string IoTestingProjectsRoot() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "IO Testing Projects"); + + private static string IoTestingEvidenceRoot() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "IO Testing Evidence"); + + private void ShowIoTestingFailure(Exception ex, string title) + { + AddLog("ERROR", "IO Testing", ex.Message); + MarkDiagnosticAlert(); + SetStatus($"{title}. Diagnostics is marked with !."); + MessageBox.Show(this, ex.Message, title, MessageBoxButton.OK, MessageBoxImage.Error); } private void Runtime_IoTestPointUpdated(Iec61850PointSnapshot snapshot) From abe69590095db6ec61b9499ca2ea153b1fcaaef0 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 17:13:43 +0700 Subject: [PATCH 04/11] Add IO FAT autosave and handover export controls --- IoListTestingWindow.xaml.cs | 156 +++++++++++++++++++++++++++++++----- 1 file changed, 136 insertions(+), 20 deletions(-) diff --git a/IoListTestingWindow.xaml.cs b/IoListTestingWindow.xaml.cs index 1fc57c1..cf90393 100644 --- a/IoListTestingWindow.xaml.cs +++ b/IoListTestingWindow.xaml.cs @@ -3,6 +3,7 @@ using System.Windows; using ArIED61850Tester.Models.IoTesting; using ArIED61850Tester.Services.IoTesting; +using Microsoft.Win32; namespace ArIED61850Tester; @@ -11,14 +12,26 @@ public partial class IoListTestingWindow : Window, INotifyPropertyChanged private IoTestIedPlan? _selectedIed; public IoListTestingWindow() - : this(CreateEmptyProject(), CreateEmptyController()) + : this(CreateEmptyProject(), CreateEmptyController(), null) { } - public IoListTestingWindow(IoTestProject project, IoTestSessionController session) + public IoListTestingWindow( + IoTestProject project, + IoTestSessionController session, + IoTestWorkspacePersistence persistence) + : this(project, session, persistence as IoTestWorkspacePersistence?) + { + } + + private IoListTestingWindow( + IoTestProject project, + IoTestSessionController session, + IoTestWorkspacePersistence? persistence) { Project = project ?? throw new ArgumentNullException(nameof(project)); Session = session ?? throw new ArgumentNullException(nameof(session)); + Storage = persistence; Project.InitializeRuntimeNotifications(); InitializeComponent(); DataContext = this; @@ -27,6 +40,7 @@ public IoListTestingWindow(IoTestProject project, IoTestSessionController sessio public IoTestProject Project { get; } public IoTestSessionController Session { get; } + public IoTestWorkspacePersistence? Storage { get; } public IoTestIedPlan? SelectedIed { @@ -59,40 +73,135 @@ private void StartSession_Click(object sender, RoutedEventArgs e) return; } - ShowActionResult(Session.Start(SelectedIed), "FAT session could not start"); + var result = Session.Start(SelectedIed); + ShowActionResult(result, "FAT session could not start"); + if (result.Succeeded) + Storage?.ScheduleSave(); } private void PauseSession_Click(object sender, RoutedEventArgs e) - => ShowActionResult(Session.Pause(), "FAT session could not pause"); + { + var result = Session.Pause(); + ShowActionResult(result, "FAT session could not pause"); + if (result.Succeeded) + Storage?.SaveNow(); + } private void ResumeSession_Click(object sender, RoutedEventArgs e) - => ShowActionResult(Session.Resume(), "FAT session could not resume"); + { + var result = Session.Resume(); + ShowActionResult(result, "FAT session could not resume"); + if (result.Succeeded) + Storage?.ScheduleSave(); + } private void StopSession_Click(object sender, RoutedEventArgs e) - => ShowActionResult(Session.Stop(), "FAT session could not stop"); + { + var result = Session.Stop(); + ShowActionResult(result, "FAT session could not stop"); + if (result.Succeeded) + Storage?.SaveNow(); + } - private void ReturnToEngineering_Click(object sender, RoutedEventArgs e) - => Close(); + private void SaveProgress_Click(object sender, RoutedEventArgs e) + { + try + { + Storage?.SaveNow(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + MessageBox.Show(this, ex.Message, "Progress save failed", MessageBoxButton.OK, MessageBoxImage.Error); + } + } - private void Window_Closing(object? sender, CancelEventArgs e) + private async void ExportHandover_Click(object sender, RoutedEventArgs e) { - if (!Session.IsSessionActive) + if (Storage == null) return; + if (Session.IsSessionActive) + { + MessageBox.Show( + this, + "Stop the active IED session before exporting. This seals and verifies the evidence journal before it is transferred to another laptop.", + "Stop session before export", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } - var answer = MessageBox.Show( - this, - "A FAT session is active. Returning to Engineering will stop the session and seal the current evidence journal.\n\nStop the session and return?", - "Stop active FAT session", - MessageBoxButton.YesNo, - MessageBoxImage.Warning, - MessageBoxResult.No); - if (answer != MessageBoxResult.Yes) + var dialog = new SaveFileDialog { - e.Cancel = true; + Title = "Export portable ARSAS IO FAT handover", + Filter = $"ARSAS IO FAT handover (*{IoTestWorkspacePersistence.PackageExtension})|*{IoTestWorkspacePersistence.PackageExtension}", + FileName = $"{SafeFileName(Project.ProjectId)}_{DateTime.Now:yyyyMMdd_HHmm}{IoTestWorkspacePersistence.PackageExtension}", + AddExtension = true, + DefaultExt = IoTestWorkspacePersistence.PackageExtension, + OverwritePrompt = true + }; + if (dialog.ShowDialog(this) != true) return; + + try + { + IsEnabled = false; + await Storage.ExportPackageAsync(dialog.FileName); + MessageBox.Show( + this, + $"Portable FAT handover created successfully.\n\n{Storage.LastExportPath}\n\nThe package can be opened in ARSAS on another laptop. It also contains report/IO-FAT-Report.html for browser Print to PDF.", + "FAT handover exported", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + { + MessageBox.Show(this, ex.Message, "FAT handover export failed", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsEnabled = true; + } + } + + private void ReturnToEngineering_Click(object sender, RoutedEventArgs e) + => Close(); + + private void Window_Closing(object? sender, CancelEventArgs e) + { + if (Session.IsSessionActive) + { + var answer = MessageBox.Show( + this, + "A FAT session is active. Returning to Engineering will stop the session, seal the evidence journal, and save the current project progress.\n\nStop the session and return?", + "Stop active FAT session", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + { + e.Cancel = true; + return; + } + + Session.Stop("Workspace closed by operator; evidence journal sealed."); } - Session.Stop("Workspace closed by operator; evidence journal sealed."); + try + { + Storage?.SaveNow(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + var answer = MessageBox.Show( + this, + $"ARSAS could not save the latest IO FAT progress.\n\n{ex.Message}\n\nClose the workspace anyway?", + "Progress save failed", + MessageBoxButton.YesNo, + MessageBoxImage.Error, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + e.Cancel = true; + } } private void ShowActionResult(IoTestSessionActionResult result, string title) @@ -102,6 +211,13 @@ private void ShowActionResult(IoTestSessionActionResult result, string title) MessageBox.Show(this, result.Message, title, MessageBoxButton.OK, MessageBoxImage.Warning); } + private static string SafeFileName(string value) + { + var invalid = Path.GetInvalidFileNameChars().ToHashSet(); + var result = new string((value ?? "IO-FAT").Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); + return result.Length == 0 ? "IO-FAT" : result; + } + private void Raise([CallerMemberName] string? propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); From e06ee096c98f0b16e2ee56dca5bf5b49e354cb0a Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 17:14:38 +0700 Subject: [PATCH 05/11] Show IO FAT autosave and portable handover controls --- IoListTestingWindow.xaml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/IoListTestingWindow.xaml b/IoListTestingWindow.xaml index 1bc1452..6b1d9e9 100644 --- a/IoListTestingWindow.xaml +++ b/IoListTestingWindow.xaml @@ -32,6 +32,10 @@ +