diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c330036c..2c786eb3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,11 +19,16 @@ Notable public changes to ARSAS are recorded here. Application releases must ide
- Test-result artifacts in the Windows build workflow.
- A single auditable SV Evidence Bundle export containing the rendered waveform PNG, raw-sample CSV, structured manifest JSON, parser/continuity diagnostics, per-entry SHA-256 integrity file, and application/engine provenance.
- Regression coverage that opens the generated ZIP and validates its required evidence files, verdict, provenance, raw samples, diagnostics, and checksum listing.
+- A schema-versioned FAT/SAT Test & Evidence Workspace with editable IEC 61850 test cases, expected and actual outcomes, operator/witness context, deviations, execution timestamps, and evidence attachments.
+- A default bounded IEC 61850 FAT/SAT plan covering identity, MMS discovery, reporting/recovery, GOOSE, Sampled Values, guarded control, file transfer, SCL comparison, and closeout.
+- Atomic `*.arsas-fat.json` save/open and portable audit-package export containing `workspace.json`, `report.md`, immutable evidence files, SHA-256 checksums, and package provenance.
+- Regression coverage for workspace round-trip, schema rejection, complete audit-package contents, source-path redaction, and rejection of evidence changed after attachment.
### Changed
- Windows CI restores and builds the complete solution, runs application regression tests, and only then publishes the portable package.
- The SMV Snapshot Viewer enables evidence export only after a snapshot is accepted and keeps the export disabled during active capture.
+- ARSAS exposes the FAT/SAT workspace from the main header without coupling it to monitoring runtime state.
- Development version advanced to `1.6.19`. The currently published stable release remains `1.6.18` until a separately validated and tagged release is produced.
## 1.6.18
diff --git a/FatSatWorkspaceWindow.xaml b/FatSatWorkspaceWindow.xaml
new file mode 100644
index 000000000..60b348f89
--- /dev/null
+++ b/FatSatWorkspaceWindow.xaml
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FatSatWorkspaceWindow.xaml.cs b/FatSatWorkspaceWindow.xaml.cs
new file mode 100644
index 000000000..b653995a5
--- /dev/null
+++ b/FatSatWorkspaceWindow.xaml.cs
@@ -0,0 +1,455 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+using Microsoft.Win32;
+
+namespace ArIED61850Tester;
+
+public partial class FatSatWorkspaceWindow : Window, INotifyPropertyChanged
+{
+ private readonly FatSatWorkspaceService _workspaceService = new();
+ private FatSatWorkspaceDocument _document = new();
+ private FatSatTestCaseRow? _selectedRow;
+ private string _statusText = "Create, open, or execute an evidence-backed FAT/SAT workspace.";
+ private string? _currentPath;
+
+ public FatSatWorkspaceWindow()
+ {
+ InitializeComponent();
+ DataContext = this;
+ ApplyDocument(_workspaceService.CreateDefault(), null);
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public FatSatWorkspaceDocument Document
+ {
+ get => _document;
+ private set
+ {
+ _document = value;
+ Raise();
+ }
+ }
+
+ public ObservableCollection Rows { get; } = [];
+ public IReadOnlyList ResultOptions { get; } = Enum.GetValues();
+
+ public FatSatTestCaseRow? SelectedRow
+ {
+ get => _selectedRow;
+ set
+ {
+ if (ReferenceEquals(_selectedRow, value))
+ return;
+ _selectedRow = value;
+ Raise();
+ }
+ }
+
+ public string SummaryText
+ {
+ get
+ {
+ SynchronizeDocument();
+ var summary = _workspaceService.Summarize(Document);
+ return $"Total {summary.Total} · PASS {summary.Passed} · FAIL {summary.Failed} · REVIEW {summary.Review} · BLOCKED {summary.Blocked} · NOT RUN {summary.NotRun} · evidence {summary.EvidenceFiles}";
+ }
+ }
+
+ public string StatusText
+ {
+ get => _statusText;
+ private set
+ {
+ _statusText = value;
+ Raise();
+ }
+ }
+
+ public string CurrentPathText => string.IsNullOrWhiteSpace(_currentPath)
+ ? "Workspace has not been saved yet."
+ : _currentPath;
+
+ private void New_Click(object sender, RoutedEventArgs e)
+ => ApplyDocument(_workspaceService.CreateDefault(), null);
+
+ private async void Open_Click(object sender, RoutedEventArgs e)
+ {
+ var dialog = new OpenFileDialog
+ {
+ Title = "Open ARSAS FAT/SAT workspace",
+ Filter = "ARSAS FAT/SAT workspace (*.arsas-fat.json)|*.arsas-fat.json|JSON files (*.json)|*.json|All files (*.*)|*.*",
+ CheckFileExists = true
+ };
+ if (dialog.ShowDialog(this) != true)
+ return;
+
+ try
+ {
+ StatusText = "Opening FAT/SAT workspace…";
+ var document = await _workspaceService.OpenAsync(dialog.FileName);
+ ApplyDocument(document, dialog.FileName);
+ StatusText = $"Opened {Path.GetFileName(dialog.FileName)} with {Rows.Count} test case(s).";
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
+ {
+ StatusText = $"Workspace open failed: {ex.Message}";
+ MessageBox.Show(this, ex.Message, "FAT/SAT Workspace", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private async void Save_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(_currentPath))
+ {
+ var dialog = new SaveFileDialog
+ {
+ Title = "Save ARSAS FAT/SAT workspace",
+ Filter = "ARSAS FAT/SAT workspace (*.arsas-fat.json)|*.arsas-fat.json",
+ DefaultExt = ".arsas-fat.json",
+ AddExtension = true,
+ FileName = BuildDefaultWorkspaceFileName()
+ };
+ if (dialog.ShowDialog(this) != true)
+ return;
+ _currentPath = dialog.FileName;
+ }
+
+ SynchronizeDocument();
+ StatusText = "Saving FAT/SAT workspace atomically…";
+ await _workspaceService.SaveAsync(_currentPath, Document);
+ StatusText = $"Workspace saved: {Path.GetFileName(_currentPath)}.";
+ Raise(nameof(CurrentPathText));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or ArgumentException)
+ {
+ StatusText = $"Workspace save failed: {ex.Message}";
+ MessageBox.Show(this, ex.Message, "FAT/SAT Workspace", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private async void Export_Click(object sender, RoutedEventArgs e)
+ {
+ var dialog = new SaveFileDialog
+ {
+ Title = "Export FAT/SAT audit package",
+ Filter = "ARSAS FAT/SAT audit package (*.zip)|*.zip",
+ DefaultExt = ".zip",
+ AddExtension = true,
+ FileName = BuildDefaultAuditFileName()
+ };
+ if (dialog.ShowDialog(this) != true)
+ return;
+
+ try
+ {
+ SynchronizeDocument();
+ Document.UpdatedAtUtc = DateTimeOffset.UtcNow;
+ StatusText = "Verifying evidence hashes and creating FAT/SAT audit package…";
+ var result = await _workspaceService.ExportAuditPackageAsync(dialog.FileName, Document);
+ StatusText = $"Audit package exported. SHA-256: {result.PackageSha256}.";
+ MessageBox.Show(
+ this,
+ $"FAT/SAT audit package exported.\n\n{result.OutputPath}\n\nDisposition\n{(result.Summary.IsComplete ? "COMPLETE" : result.Summary.HasBlockingOutcome ? "REVIEW REQUIRED" : "INCOMPLETE")}\n\nSHA-256\n{result.PackageSha256}",
+ "FAT/SAT Audit Package",
+ MessageBoxButton.OK,
+ result.Summary.IsComplete ? MessageBoxImage.Information : MessageBoxImage.Warning);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or ArgumentException)
+ {
+ StatusText = $"Audit package export failed: {ex.Message}";
+ MessageBox.Show(this, ex.Message, "FAT/SAT Audit Package", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private void AddTest_Click(object sender, RoutedEventArgs e)
+ {
+ var next = Rows.Count + 1;
+ var model = new FatSatTestCase
+ {
+ Sequence = (next * 10).ToString("000"),
+ Area = "Custom",
+ Title = "New FAT/SAT test case",
+ Procedure = "Describe the bounded execution steps.",
+ ExpectedResult = "Describe the objective acceptance criterion."
+ };
+ var row = CreateRow(model);
+ Rows.Add(row);
+ SelectedRow = row;
+ RefreshSummary("Custom test case added.");
+ }
+
+ private void RemoveTest_Click(object sender, RoutedEventArgs e)
+ {
+ if (SelectedRow is null)
+ return;
+ var result = MessageBox.Show(
+ this,
+ $"Remove test case '{SelectedRow.Title}' and its evidence references?",
+ "Remove FAT/SAT Test Case",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+ if (result != MessageBoxResult.Yes)
+ return;
+ var index = Rows.IndexOf(SelectedRow);
+ Rows.Remove(SelectedRow);
+ SelectedRow = Rows.ElementAtOrDefault(Math.Min(index, Math.Max(0, Rows.Count - 1)));
+ RefreshSummary("Test case removed.");
+ }
+
+ private async void AttachEvidence_Click(object sender, RoutedEventArgs e)
+ {
+ if (SelectedRow is null)
+ {
+ MessageBox.Show(this, "Select a test case before attaching evidence.", "FAT/SAT Evidence", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var dialog = new OpenFileDialog
+ {
+ Title = "Attach FAT/SAT evidence",
+ Filter = "Evidence files (*.zip;*.json;*.csv;*.txt;*.log;*.png;*.jpg;*.jpeg;*.pcap;*.pcapng;*.cfg;*.dat;*.cff)|*.zip;*.json;*.csv;*.txt;*.log;*.png;*.jpg;*.jpeg;*.pcap;*.pcapng;*.cfg;*.dat;*.cff|All files (*.*)|*.*",
+ CheckFileExists = true,
+ Multiselect = true
+ };
+ if (dialog.ShowDialog(this) != true)
+ return;
+
+ try
+ {
+ StatusText = "Hashing and attaching evidence…";
+ foreach (var path in dialog.FileNames)
+ {
+ var evidence = await _workspaceService.CreateEvidenceReferenceAsync(path);
+ if (SelectedRow.Evidence.Any(item => item.Sha256.Equals(evidence.Sha256, StringComparison.OrdinalIgnoreCase)))
+ continue;
+ SelectedRow.Evidence.Add(evidence);
+ }
+ RefreshSummary($"Evidence attached to {SelectedRow.Sequence} · {SelectedRow.Title}.");
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or ArgumentException)
+ {
+ StatusText = $"Evidence attachment failed: {ex.Message}";
+ MessageBox.Show(this, ex.Message, "FAT/SAT Evidence", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private void RemoveEvidence_Click(object sender, RoutedEventArgs e)
+ {
+ if (SelectedRow?.SelectedEvidence is null)
+ return;
+ SelectedRow.Evidence.Remove(SelectedRow.SelectedEvidence);
+ SelectedRow.SelectedEvidence = null;
+ RefreshSummary("Evidence reference removed. The source file was not deleted.");
+ }
+
+ private void StampExecution_Click(object sender, RoutedEventArgs e)
+ {
+ if (SelectedRow is null)
+ return;
+ if (string.IsNullOrWhiteSpace(SelectedRow.ExecutedBy))
+ SelectedRow.ExecutedBy = Document.OperatorName;
+ SelectedRow.ExecutedAtUtc = DateTimeOffset.UtcNow;
+ RefreshSummary($"Execution timestamp recorded for {SelectedRow.Sequence}.");
+ }
+
+ private void TestGrid_CellEditEnding(object sender, DataGridCellEditEndingEventArgs e)
+ => Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(() => RefreshSummary("Test plan updated.")));
+
+ private void Close_Click(object sender, RoutedEventArgs e)
+ => Close();
+
+ private void ApplyDocument(FatSatWorkspaceDocument document, string? path)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ ApplyBuildProvenance(document);
+ Document = document;
+ _currentPath = path;
+ Rows.Clear();
+ foreach (var testCase in document.TestCases)
+ Rows.Add(CreateRow(testCase));
+ SelectedRow = Rows.FirstOrDefault();
+ StatusText = $"Workspace ready with {Rows.Count} test case(s). Outcomes remain operator-owned and evidence-backed.";
+ Raise(nameof(CurrentPathText));
+ Raise(nameof(SummaryText));
+ }
+
+ private FatSatTestCaseRow CreateRow(FatSatTestCase model)
+ {
+ var row = new FatSatTestCaseRow(model);
+ row.PropertyChanged += Row_PropertyChanged;
+ row.Evidence.CollectionChanged += Evidence_CollectionChanged;
+ return row;
+ }
+
+ private void Row_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(FatSatTestCaseRow.Result) or nameof(FatSatTestCaseRow.EvidenceCount))
+ RefreshSummary("Test outcome updated.");
+ }
+
+ private void Evidence_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ => RefreshSummary("Evidence set updated.");
+
+ private void RefreshSummary(string status)
+ {
+ SynchronizeDocument();
+ StatusText = status;
+ Raise(nameof(SummaryText));
+ }
+
+ private void SynchronizeDocument()
+ {
+ Document.TestCases = Rows.Select(row => row.Model).ToList();
+ foreach (var row in Rows)
+ row.SynchronizeEvidence();
+ }
+
+ private void ApplyBuildProvenance(FatSatWorkspaceDocument document)
+ {
+ var provenance = ReadBuildProvenance();
+ document.ApplicationVersion = provenance.ApplicationVersion;
+ document.ApplicationCommit = provenance.ApplicationCommit;
+ document.EngineRepository = provenance.EngineRepository;
+ document.EngineReference = provenance.EngineReference;
+ document.EngineCommit = provenance.EngineCommit;
+ }
+
+ private static BuildProvenance ReadBuildProvenance()
+ {
+ var assembly = typeof(FatSatWorkspaceWindow).Assembly;
+ var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion
+ ?? assembly.GetName().Version?.ToString()
+ ?? "unknown";
+ var plusIndex = informationalVersion.IndexOf('+');
+ var version = plusIndex >= 0 ? informationalVersion[..plusIndex] : informationalVersion;
+ var applicationCommit = plusIndex >= 0 ? informationalVersion[(plusIndex + 1)..] : "not-embedded";
+ using var stream = assembly.GetManifestResourceStream("ARSAS.ARIEC61850.lock.json")
+ ?? throw new InvalidOperationException("Embedded ARIEC61850 provenance lock was not found.");
+ using var document = JsonDocument.Parse(stream);
+ var root = document.RootElement;
+ return new BuildProvenance(
+ version,
+ applicationCommit,
+ root.GetProperty("repository").GetString() ?? "unknown",
+ root.GetProperty("ref").GetString() ?? "unknown",
+ root.GetProperty("commit").GetString() ?? "unknown");
+ }
+
+ private string BuildDefaultWorkspaceFileName()
+ => $"{SanitizeFileName(Document.ProjectName)}.arsas-fat.json";
+
+ private string BuildDefaultAuditFileName()
+ => $"ARSAS-FAT-SAT-{SanitizeFileName(Document.ProjectName)}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}Z.zip";
+
+ private static string SanitizeFileName(string value)
+ {
+ var invalid = Path.GetInvalidFileNameChars();
+ var sanitized = new string((value ?? string.Empty).Select(character => invalid.Contains(character) ? '-' : character).ToArray()).Trim();
+ return string.IsNullOrWhiteSpace(sanitized) ? "workspace" : sanitized;
+ }
+
+ private void Raise([CallerMemberName] string? propertyName = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ private sealed record BuildProvenance(
+ string ApplicationVersion,
+ string ApplicationCommit,
+ string EngineRepository,
+ string EngineReference,
+ string EngineCommit);
+}
+
+public sealed class FatSatTestCaseRow : INotifyPropertyChanged
+{
+ private FatSatEvidenceReference? _selectedEvidence;
+
+ public FatSatTestCaseRow(FatSatTestCase model)
+ {
+ Model = model ?? throw new ArgumentNullException(nameof(model));
+ Evidence = new ObservableCollection(model.Evidence);
+ Evidence.CollectionChanged += (_, _) =>
+ {
+ SynchronizeEvidence();
+ Raise(nameof(EvidenceCount));
+ };
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ public FatSatTestCase Model { get; }
+ public ObservableCollection Evidence { get; }
+ public int EvidenceCount => Evidence.Count;
+
+ public FatSatEvidenceReference? SelectedEvidence
+ {
+ get => _selectedEvidence;
+ set
+ {
+ _selectedEvidence = value;
+ Raise();
+ }
+ }
+
+ public string Sequence { get => Model.Sequence; set => SetText(Model.Sequence, value, assigned => Model.Sequence = assigned); }
+ public string Area { get => Model.Area; set => SetText(Model.Area, value, assigned => Model.Area = assigned); }
+ public string Title { get => Model.Title; set => SetText(Model.Title, value, assigned => Model.Title = assigned); }
+ public string Procedure { get => Model.Procedure; set => SetText(Model.Procedure, value, assigned => Model.Procedure = assigned); }
+ public string ExpectedResult { get => Model.ExpectedResult; set => SetText(Model.ExpectedResult, value, assigned => Model.ExpectedResult = assigned); }
+ public string ActualResult { get => Model.ActualResult; set => SetText(Model.ActualResult, value, assigned => Model.ActualResult = assigned); }
+ public string OperatorNote { get => Model.OperatorNote; set => SetText(Model.OperatorNote, value, assigned => Model.OperatorNote = assigned); }
+ public string ExceptionOrDeviation { get => Model.ExceptionOrDeviation; set => SetText(Model.ExceptionOrDeviation, value, assigned => Model.ExceptionOrDeviation = assigned); }
+ public string ExecutedBy { get => Model.ExecutedBy; set => SetText(Model.ExecutedBy, value, assigned => Model.ExecutedBy = assigned); }
+
+ public DateTimeOffset? ExecutedAtUtc
+ {
+ get => Model.ExecutedAtUtc;
+ set
+ {
+ if (Model.ExecutedAtUtc == value)
+ return;
+ Model.ExecutedAtUtc = value;
+ Raise();
+ }
+ }
+
+ public FatSatTestResult Result
+ {
+ get => Model.Result;
+ set
+ {
+ if (Model.Result == value)
+ return;
+ Model.Result = value;
+ if (value != FatSatTestResult.NotRun && Model.ExecutedAtUtc is null)
+ Model.ExecutedAtUtc = DateTimeOffset.UtcNow;
+ Raise();
+ Raise(nameof(ExecutedAtUtc));
+ }
+ }
+
+ public void SynchronizeEvidence()
+ => Model.Evidence = Evidence.ToList();
+
+ private void SetText(string current, string? value, Action assign, [CallerMemberName] string? propertyName = null)
+ {
+ var normalized = value ?? string.Empty;
+ if (string.Equals(current, normalized, StringComparison.Ordinal))
+ return;
+ assign(normalized);
+ Raise(propertyName);
+ }
+
+ private void Raise([CallerMemberName] string? propertyName = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+}
diff --git a/Models/FatSatWorkspaceDocument.cs b/Models/FatSatWorkspaceDocument.cs
new file mode 100644
index 000000000..ba48c455f
--- /dev/null
+++ b/Models/FatSatWorkspaceDocument.cs
@@ -0,0 +1,80 @@
+using System.Text.Json.Serialization;
+
+namespace ArIED61850Tester.Models;
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum FatSatTestResult
+{
+ NotRun,
+ Pass,
+ Fail,
+ Review,
+ Blocked,
+ NotApplicable
+}
+
+public sealed class FatSatWorkspaceDocument
+{
+ public const int CurrentSchemaVersion = 1;
+
+ public int SchemaVersion { get; set; } = CurrentSchemaVersion;
+ public Guid WorkspaceId { get; set; } = Guid.NewGuid();
+ public string ProjectName { get; set; } = "IEC 61850 FAT/SAT Workspace";
+ public string SiteName { get; set; } = string.Empty;
+ public string BayOrSystem { get; set; } = string.Empty;
+ public string IedIdentity { get; set; } = string.Empty;
+ public string OperatorName { get; set; } = string.Empty;
+ public string WitnessName { get; set; } = string.Empty;
+ public string Scope { get; set; } = string.Empty;
+ public string ApplicationVersion { get; set; } = string.Empty;
+ public string ApplicationCommit { get; set; } = string.Empty;
+ public string EngineRepository { get; set; } = string.Empty;
+ public string EngineReference { get; set; } = string.Empty;
+ public string EngineCommit { get; set; } = string.Empty;
+ public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+ public List TestCases { get; set; } = [];
+}
+
+public sealed class FatSatTestCase
+{
+ public Guid TestCaseId { get; set; } = Guid.NewGuid();
+ public string Sequence { get; set; } = string.Empty;
+ public string Area { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+ public string Procedure { get; set; } = string.Empty;
+ public string ExpectedResult { get; set; } = string.Empty;
+ public FatSatTestResult Result { get; set; } = FatSatTestResult.NotRun;
+ public string ActualResult { get; set; } = string.Empty;
+ public string OperatorNote { get; set; } = string.Empty;
+ public string ExceptionOrDeviation { get; set; } = string.Empty;
+ public DateTimeOffset? ExecutedAtUtc { get; set; }
+ public string ExecutedBy { get; set; } = string.Empty;
+ public List Evidence { get; set; } = [];
+}
+
+public sealed class FatSatEvidenceReference
+{
+ public Guid EvidenceId { get; set; } = Guid.NewGuid();
+ public string DisplayName { get; set; } = string.Empty;
+ public string SourcePath { get; set; } = string.Empty;
+ public string Sha256 { get; set; } = string.Empty;
+ public long SizeBytes { get; set; }
+ public string MediaType { get; set; } = "application/octet-stream";
+ public DateTimeOffset AttachedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+ public string Description { get; set; } = string.Empty;
+}
+
+public sealed record FatSatWorkspaceSummary(
+ int Total,
+ int NotRun,
+ int Passed,
+ int Failed,
+ int Review,
+ int Blocked,
+ int NotApplicable,
+ int EvidenceFiles)
+{
+ public bool HasBlockingOutcome => Failed > 0 || Review > 0 || Blocked > 0;
+ public bool IsComplete => Total > 0 && NotRun == 0 && !HasBlockingOutcome;
+}
diff --git a/Services/FatSatWorkspaceBootstrap.cs b/Services/FatSatWorkspaceBootstrap.cs
new file mode 100644
index 000000000..e2acb6a82
--- /dev/null
+++ b/Services/FatSatWorkspaceBootstrap.cs
@@ -0,0 +1,51 @@
+using System.Runtime.CompilerServices;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+
+namespace ArIED61850Tester.Services;
+
+internal static class FatSatWorkspaceBootstrap
+{
+ private const string ButtonName = "P2FatSatWorkspaceButton";
+
+ [ModuleInitializer]
+ internal static void Initialize()
+ => EventManager.RegisterClassHandler(
+ typeof(MainWindow),
+ FrameworkElement.LoadedEvent,
+ new RoutedEventHandler(MainWindow_Loaded));
+
+ private static void MainWindow_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MainWindow window || window.FindName(ButtonName) is not null)
+ return;
+ if (window.FindName("WorkflowNavShell") is not FrameworkElement navigation || VisualTreeHelper.GetParent(navigation) is not Grid topNavigation)
+ return;
+
+ var actions = topNavigation.Children
+ .OfType()
+ .FirstOrDefault(panel => Grid.GetColumn(panel) == 2);
+ if (actions is null)
+ return;
+
+ var button = new Button
+ {
+ Name = ButtonName,
+ Content = "FAT/SAT Workspace",
+ Padding = new Thickness(12, 7, 12, 7),
+ Margin = new Thickness(0, 0, 8, 0),
+ ToolTip = "Open the schema-versioned IEC 61850 FAT/SAT test and evidence workspace."
+ };
+ if (window.TryFindResource("SoftButton") is Style style)
+ button.Style = style;
+ button.Click += (_, _) =>
+ {
+ var workspace = new FatSatWorkspaceWindow { Owner = window };
+ workspace.ShowDialog();
+ };
+
+ actions.Children.Insert(0, button);
+ window.RegisterName(ButtonName, button);
+ }
+}
diff --git a/Services/FatSatWorkspaceService.cs b/Services/FatSatWorkspaceService.cs
new file mode 100644
index 000000000..3d1be2951
--- /dev/null
+++ b/Services/FatSatWorkspaceService.cs
@@ -0,0 +1,335 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+public sealed record FatSatAuditPackageResult(
+ string OutputPath,
+ string PackageSha256,
+ FatSatWorkspaceSummary Summary,
+ IReadOnlyDictionary EntrySha256);
+
+public sealed class FatSatWorkspaceService
+{
+ private const long MaximumEvidenceFileBytes = 256L * 1024L * 1024L;
+ private static readonly UTF8Encoding Utf8NoBom = new(false);
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ public FatSatWorkspaceDocument CreateDefault()
+ => new()
+ {
+ Scope = "Bounded IEC 61850 FAT/SAT execution with evidence-backed outcomes. Formal conformance and universal interoperability remain outside scope unless separately proven.",
+ TestCases =
+ [
+ CreateCase("001", "Identity", "Confirm IED identity and project binding", "Record the selected IED name, endpoint, SCL source, firmware identity when available, and test boundary.", "The tested asset is unambiguously identified and bound to this workspace."),
+ CreateCase("010", "MMS", "Discover IEC 61850 server model", "Connect through MMS and discover Logical Devices, Logical Nodes, Data Objects, Data Attributes, DataSets, and control blocks.", "The model is discovered without unresolved fatal errors and the intended IED is represented."),
+ CreateCase("020", "Reporting", "Validate report acquisition and recovery", "Enable the selected report path, observe updates, interrupt the association, reconnect, and verify recovery or documented fallback.", "Selected values update with traceable source and recover after reconnect without cross-IED contamination."),
+ CreateCase("030", "GOOSE", "Validate subscribed GOOSE state and timing", "Observe the selected GOOSE control block, stNum/sqNum progression, TAL state, duplicates, and sequence anomalies.", "Stream identity is correct and observed state/timing findings match the expected test condition."),
+ CreateCase("040", "Sampled Values", "Capture bounded SV evidence", "Capture the selected IEC 61850-9-2 stream for the required window and attach the ARSAS SV Evidence Bundle.", "The evidence bundle binds stream identity, payload shape, counter continuity, provenance, and explicit semantic boundary."),
+ CreateCase("050", "Control", "Validate guarded control workflow", "Confirm ctlModel, command selection, interlock/synchrocheck policy, command termination, and process feedback using an authorized isolated test setup.", "The command result and feedback are consistent, time-bounded, and evidence-backed; unsafe or unauthorized operation is not performed."),
+ CreateCase("060", "Files", "Validate IEC 61850 file transfer", "List the configured remote path, transfer a representative record, verify completion, and attach the received artifact or checksum.", "The file is transferred completely with stable identity and recorded integrity evidence."),
+ CreateCase("070", "SCL", "Compare configured, generated, and live context", "Review the imported SCL source, generated bounded export, and available live model evidence for material differences.", "Material differences are recorded, explained, and accepted or left as explicit deviations."),
+ CreateCase("090", "Closeout", "Review deviations and acceptance boundary", "Review FAIL, REVIEW, BLOCKED, and open deviations with the operator and witness before package export.", "No unresolved outcome is hidden; acceptance status and remaining field actions are explicit.")
+ ]
+ };
+
+ public FatSatWorkspaceSummary Summarize(FatSatWorkspaceDocument document)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ return new FatSatWorkspaceSummary(
+ document.TestCases.Count,
+ document.TestCases.Count(item => item.Result == FatSatTestResult.NotRun),
+ document.TestCases.Count(item => item.Result == FatSatTestResult.Pass),
+ document.TestCases.Count(item => item.Result == FatSatTestResult.Fail),
+ document.TestCases.Count(item => item.Result == FatSatTestResult.Review),
+ document.TestCases.Count(item => item.Result == FatSatTestResult.Blocked),
+ document.TestCases.Count(item => item.Result == FatSatTestResult.NotApplicable),
+ document.TestCases.Sum(item => item.Evidence.Count));
+ }
+
+ public async Task SaveAsync(
+ string path,
+ FatSatWorkspaceDocument document,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ Validate(document);
+ document.UpdatedAtUtc = DateTimeOffset.UtcNow;
+ var fullPath = Path.GetFullPath(path);
+ Directory.CreateDirectory(Path.GetDirectoryName(fullPath) ?? Environment.CurrentDirectory);
+ var temporaryPath = fullPath + ".partial";
+ try
+ {
+ await File.WriteAllBytesAsync(
+ temporaryPath,
+ JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions),
+ cancellationToken);
+ File.Move(temporaryPath, fullPath, overwrite: true);
+ }
+ catch
+ {
+ if (File.Exists(temporaryPath))
+ File.Delete(temporaryPath);
+ throw;
+ }
+ }
+
+ public async Task OpenAsync(
+ string path,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ await using var stream = new FileStream(
+ Path.GetFullPath(path),
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ 64 * 1024,
+ useAsync: true);
+ var document = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken)
+ ?? throw new InvalidDataException("FAT/SAT workspace is empty or malformed.");
+ Validate(document);
+ return document;
+ }
+
+ public async Task CreateEvidenceReferenceAsync(
+ string path,
+ string description = "",
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ var fullPath = Path.GetFullPath(path);
+ var file = new FileInfo(fullPath);
+ if (!file.Exists)
+ throw new FileNotFoundException("Evidence file was not found.", fullPath);
+ if (file.Length <= 0)
+ throw new InvalidDataException("Empty evidence files cannot be attached.");
+ if (file.Length > MaximumEvidenceFileBytes)
+ throw new InvalidDataException($"Evidence file exceeds the {MaximumEvidenceFileBytes / (1024 * 1024)} MB package limit.");
+
+ await using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, useAsync: true);
+ var hash = await SHA256.HashDataAsync(stream, cancellationToken);
+ return new FatSatEvidenceReference
+ {
+ DisplayName = file.Name,
+ SourcePath = fullPath,
+ Sha256 = Convert.ToHexString(hash).ToLowerInvariant(),
+ SizeBytes = file.Length,
+ MediaType = ResolveMediaType(file.Extension),
+ AttachedAtUtc = DateTimeOffset.UtcNow,
+ Description = description?.Trim() ?? string.Empty
+ };
+ }
+
+ public async Task ExportAuditPackageAsync(
+ string outputPath,
+ FatSatWorkspaceDocument document,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
+ Validate(document);
+ var fullPath = Path.GetFullPath(outputPath);
+ Directory.CreateDirectory(Path.GetDirectoryName(fullPath) ?? Environment.CurrentDirectory);
+ var temporaryPath = fullPath + ".partial";
+ if (File.Exists(temporaryPath))
+ File.Delete(temporaryPath);
+
+ var summary = Summarize(document);
+ var packageDocument = CloneForPackage(document);
+ var entries = new SortedDictionary(StringComparer.Ordinal)
+ {
+ ["workspace.json"] = JsonSerializer.SerializeToUtf8Bytes(packageDocument, JsonOptions),
+ ["report.md"] = Utf8NoBom.GetBytes(BuildMarkdownReport(packageDocument, summary))
+ };
+
+ foreach (var testCase in document.TestCases)
+ {
+ foreach (var evidence in testCase.Evidence)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var source = Path.GetFullPath(evidence.SourcePath);
+ if (!File.Exists(source))
+ throw new FileNotFoundException($"Evidence file '{evidence.DisplayName}' is missing.", source);
+ var bytes = await File.ReadAllBytesAsync(source, cancellationToken);
+ if (bytes.LongLength != evidence.SizeBytes)
+ throw new InvalidDataException($"Evidence size changed after attachment: {evidence.DisplayName}.");
+ var actualHash = ComputeSha256(bytes);
+ if (!actualHash.Equals(evidence.Sha256, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException($"Evidence hash changed after attachment: {evidence.DisplayName}.");
+ entries[BuildEvidenceEntryPath(testCase, evidence)] = bytes;
+ }
+ }
+
+ var hashes = entries.ToDictionary(pair => pair.Key, pair => ComputeSha256(pair.Value), StringComparer.Ordinal);
+ entries["SHA256SUMS.txt"] = Utf8NoBom.GetBytes(BuildChecksums(hashes));
+
+ try
+ {
+ await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 64 * 1024, useAsync: true))
+ {
+ using var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
+ foreach (var pair in entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var entry = archive.CreateEntry(pair.Key, CompressionLevel.Optimal);
+ entry.LastWriteTime = document.UpdatedAtUtc;
+ await using var entryStream = entry.Open();
+ await entryStream.WriteAsync(pair.Value, cancellationToken);
+ }
+ }
+ File.Move(temporaryPath, fullPath, overwrite: true);
+ var packageHash = ComputeSha256(await File.ReadAllBytesAsync(fullPath, cancellationToken));
+ return new FatSatAuditPackageResult(fullPath, packageHash, summary, hashes);
+ }
+ catch
+ {
+ if (File.Exists(temporaryPath))
+ File.Delete(temporaryPath);
+ throw;
+ }
+ }
+
+ public void Validate(FatSatWorkspaceDocument document)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ if (document.SchemaVersion != FatSatWorkspaceDocument.CurrentSchemaVersion)
+ throw new InvalidDataException($"Unsupported FAT/SAT schema version {document.SchemaVersion}. Expected {FatSatWorkspaceDocument.CurrentSchemaVersion}.");
+ if (document.WorkspaceId == Guid.Empty)
+ throw new InvalidDataException("Workspace identity is missing.");
+ if (document.TestCases.Count > 1000)
+ throw new InvalidDataException("Workspace exceeds the 1,000 test-case safety limit.");
+ var duplicate = document.TestCases.GroupBy(item => item.TestCaseId).FirstOrDefault(group => group.Key == Guid.Empty || group.Count() > 1);
+ if (duplicate is not null)
+ throw new InvalidDataException("Test-case identities must be non-empty and unique.");
+ foreach (var testCase in document.TestCases)
+ {
+ if (string.IsNullOrWhiteSpace(testCase.Title))
+ throw new InvalidDataException("Every FAT/SAT test case requires a title.");
+ if (testCase.Evidence.Count > 100)
+ throw new InvalidDataException($"Test case '{testCase.Title}' exceeds the 100-evidence-file safety limit.");
+ var duplicateEvidence = testCase.Evidence.GroupBy(item => item.EvidenceId).FirstOrDefault(group => group.Key == Guid.Empty || group.Count() > 1);
+ if (duplicateEvidence is not null)
+ throw new InvalidDataException($"Test case '{testCase.Title}' contains duplicate evidence identities.");
+ }
+ }
+
+ private static FatSatTestCase CreateCase(string sequence, string area, string title, string procedure, string expected)
+ => new()
+ {
+ Sequence = sequence,
+ Area = area,
+ Title = title,
+ Procedure = procedure,
+ ExpectedResult = expected
+ };
+
+ private static FatSatWorkspaceDocument CloneForPackage(FatSatWorkspaceDocument source)
+ {
+ var clone = JsonSerializer.Deserialize(JsonSerializer.Serialize(source, JsonOptions), JsonOptions)
+ ?? throw new InvalidOperationException("FAT/SAT workspace could not be cloned for packaging.");
+ foreach (var testCase in clone.TestCases)
+ {
+ foreach (var evidence in testCase.Evidence)
+ evidence.SourcePath = BuildEvidenceEntryPath(testCase, evidence);
+ }
+ return clone;
+ }
+
+ private static string BuildEvidenceEntryPath(FatSatTestCase testCase, FatSatEvidenceReference evidence)
+ {
+ var sequence = SanitizeFileName(string.IsNullOrWhiteSpace(testCase.Sequence) ? "test" : testCase.Sequence);
+ var name = SanitizeFileName(string.IsNullOrWhiteSpace(evidence.DisplayName) ? "evidence.bin" : evidence.DisplayName);
+ return $"evidence/{sequence}-{testCase.TestCaseId:N}/{evidence.EvidenceId:N}-{name}";
+ }
+
+ private static string BuildMarkdownReport(FatSatWorkspaceDocument document, FatSatWorkspaceSummary summary)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("# ARSAS FAT/SAT Test & Evidence Report");
+ builder.AppendLine();
+ builder.AppendLine($"- Project: {Readable(document.ProjectName)}");
+ builder.AppendLine($"- Site: {Readable(document.SiteName)}");
+ builder.AppendLine($"- Bay/System: {Readable(document.BayOrSystem)}");
+ builder.AppendLine($"- IED: {Readable(document.IedIdentity)}");
+ builder.AppendLine($"- Operator: {Readable(document.OperatorName)}");
+ builder.AppendLine($"- Witness: {Readable(document.WitnessName)}");
+ builder.AppendLine($"- Updated UTC: {document.UpdatedAtUtc:O}");
+ builder.AppendLine($"- ARSAS: {Readable(document.ApplicationVersion)} / {Readable(document.ApplicationCommit)}");
+ builder.AppendLine($"- Engine: {Readable(document.EngineRepository)} @ {Readable(document.EngineCommit)}");
+ builder.AppendLine();
+ builder.AppendLine("## Summary");
+ builder.AppendLine();
+ builder.AppendLine($"Total {summary.Total}; PASS {summary.Passed}; FAIL {summary.Failed}; REVIEW {summary.Review}; BLOCKED {summary.Blocked}; NOT RUN {summary.NotRun}; N/A {summary.NotApplicable}; evidence {summary.EvidenceFiles}.");
+ builder.AppendLine();
+ builder.AppendLine($"Package disposition: {(summary.IsComplete ? "COMPLETE / NO BLOCKING OUTCOME" : summary.HasBlockingOutcome ? "REVIEW REQUIRED" : "INCOMPLETE")}");
+ builder.AppendLine();
+ builder.AppendLine("## Scope and boundary");
+ builder.AppendLine();
+ builder.AppendLine(Readable(document.Scope));
+ builder.AppendLine();
+ builder.AppendLine("## Test cases");
+ builder.AppendLine();
+ foreach (var testCase in document.TestCases.OrderBy(item => item.Sequence, StringComparer.OrdinalIgnoreCase))
+ {
+ builder.AppendLine($"### {Readable(testCase.Sequence)} · {Readable(testCase.Area)} · {Readable(testCase.Title)}");
+ builder.AppendLine();
+ builder.AppendLine($"- Result: **{testCase.Result.ToString().ToUpperInvariant()}**");
+ builder.AppendLine($"- Expected: {Readable(testCase.ExpectedResult)}");
+ builder.AppendLine($"- Actual: {Readable(testCase.ActualResult)}");
+ builder.AppendLine($"- Operator note: {Readable(testCase.OperatorNote)}");
+ builder.AppendLine($"- Deviation: {Readable(testCase.ExceptionOrDeviation)}");
+ builder.AppendLine($"- Executed by/UTC: {Readable(testCase.ExecutedBy)} / {(testCase.ExecutedAtUtc.HasValue ? testCase.ExecutedAtUtc.Value.ToString("O") : "-")}");
+ if (testCase.Evidence.Count == 0)
+ {
+ builder.AppendLine("- Evidence: none attached");
+ }
+ else
+ {
+ builder.AppendLine("- Evidence:");
+ foreach (var evidence in testCase.Evidence)
+ builder.AppendLine($" - `{BuildEvidenceEntryPath(testCase, evidence)}` · SHA-256 `{evidence.Sha256}` · {evidence.SizeBytes} bytes");
+ }
+ builder.AppendLine();
+ }
+ return builder.ToString();
+ }
+
+ private static string BuildChecksums(IReadOnlyDictionary hashes)
+ => string.Join("\n", hashes.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Value} {pair.Key}")) + "\n";
+
+ private static string ComputeSha256(byte[] bytes)
+ => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+
+ private static string Readable(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim().Replace("\r", " ").Replace("\n", " ");
+
+ private static string SanitizeFileName(string value)
+ {
+ var invalid = Path.GetInvalidFileNameChars();
+ var result = new string(value.Select(character => invalid.Contains(character) ? '-' : character).ToArray()).Trim();
+ return string.IsNullOrWhiteSpace(result) ? "item" : result;
+ }
+
+ private static string ResolveMediaType(string extension)
+ => extension.ToLowerInvariant() switch
+ {
+ ".zip" => "application/zip",
+ ".json" => "application/json",
+ ".csv" => "text/csv",
+ ".txt" or ".log" or ".md" => "text/plain",
+ ".png" => "image/png",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".pcap" or ".pcapng" => "application/vnd.tcpdump.pcap",
+ ".cfg" or ".dat" or ".cff" => "application/octet-stream",
+ _ => "application/octet-stream"
+ };
+}
diff --git a/docs/FAT-SAT-WORKSPACE.md b/docs/FAT-SAT-WORKSPACE.md
new file mode 100644
index 000000000..e1692431e
--- /dev/null
+++ b/docs/FAT-SAT-WORKSPACE.md
@@ -0,0 +1,63 @@
+# ARSAS FAT/SAT Test & Evidence Workspace
+
+The FAT/SAT workspace is a bounded execution and evidence workflow. It does not turn an operator-entered result into formal IEC 61850 conformance evidence by itself.
+
+## Workspace contract
+
+- File format: `*.arsas-fat.json`
+- Current schema: `1`
+- Workspace, test-case, and evidence identities are stable GUIDs.
+- Save uses a temporary file and atomic replacement.
+- Unsupported future schema versions are rejected instead of guessed or silently migrated.
+- A workspace is limited to 1,000 test cases and 100 evidence files per test case.
+- One evidence file is limited to 256 MB.
+
+## Test-case outcome
+
+Supported outcomes are:
+
+- `NotRun`
+- `Pass`
+- `Fail`
+- `Review`
+- `Blocked`
+- `NotApplicable`
+
+A package is complete only when no test remains `NotRun` and no test is `Fail`, `Review`, or `Blocked`. The operator remains responsible for the result and its supporting evidence.
+
+## Evidence integrity
+
+When a file is attached, ARSAS records:
+
+- display name;
+- absolute source path in the local working document;
+- SHA-256;
+- byte length;
+- media type;
+- attachment UTC time;
+- optional description.
+
+Before audit-package export, ARSAS reads every source file again. Export is rejected when the file is missing, its size changed, or its SHA-256 no longer matches.
+
+## Audit package
+
+The ZIP package contains:
+
+- `workspace.json` — portable workspace with source paths rewritten to package-relative evidence paths;
+- `report.md` — human-readable scope, summary, disposition, test outcomes, deviations, and evidence references;
+- `evidence/...` — immutable attached evidence files;
+- `SHA256SUMS.txt` — SHA-256 for every package entry except the checksum file itself.
+
+The final ZIP also receives a package-level SHA-256 shown to the operator.
+
+## Provenance
+
+The workspace records:
+
+- ARSAS version and informational build identity;
+- ARIEC61850 repository, ref, and immutable commit;
+- project, site, bay/system, IED, operator, witness, and scope.
+
+## Acceptance boundary
+
+The workspace supports repeatable FAT/SAT execution and traceable evidence. Formal conformance, universal interoperability, calibrated measurement, and authorization for live process control require separate evidence and governance.
diff --git a/tests/ARSAS.Tests/FatSatWorkspaceServiceTests.cs b/tests/ARSAS.Tests/FatSatWorkspaceServiceTests.cs
new file mode 100644
index 000000000..e78c5a06b
--- /dev/null
+++ b/tests/ARSAS.Tests/FatSatWorkspaceServiceTests.cs
@@ -0,0 +1,179 @@
+using System.IO.Compression;
+using System.Text.Json;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class FatSatWorkspaceServiceTests
+{
+ [Fact]
+ public void CreateDefault_ProvidesBoundedIec61850TestPlan()
+ {
+ var service = new FatSatWorkspaceService();
+ var document = service.CreateDefault();
+ var summary = service.Summarize(document);
+
+ Assert.Equal(FatSatWorkspaceDocument.CurrentSchemaVersion, document.SchemaVersion);
+ Assert.True(document.TestCases.Count >= 8);
+ Assert.Contains(document.TestCases, item => item.Area == "Sampled Values");
+ Assert.Contains(document.TestCases, item => item.Area == "Control");
+ Assert.Equal(document.TestCases.Count, summary.NotRun);
+ Assert.False(summary.IsComplete);
+ }
+
+ [Fact]
+ public async Task SaveAndOpen_RoundTripsSchemaAndOutcomes()
+ {
+ var directory = CreateTemporaryDirectory();
+ try
+ {
+ var path = Path.Combine(directory, "project.arsas-fat.json");
+ var service = new FatSatWorkspaceService();
+ var document = service.CreateDefault();
+ document.ProjectName = "Substation A FAT";
+ document.OperatorName = "Operator 1";
+ document.TestCases[0].Result = FatSatTestResult.Pass;
+ document.TestCases[0].ActualResult = "Identity confirmed.";
+
+ await service.SaveAsync(path, document);
+ var reopened = await service.OpenAsync(path);
+
+ Assert.Equal(document.WorkspaceId, reopened.WorkspaceId);
+ Assert.Equal("Substation A FAT", reopened.ProjectName);
+ Assert.Equal(FatSatTestResult.Pass, reopened.TestCases[0].Result);
+ Assert.Equal("Identity confirmed.", reopened.TestCases[0].ActualResult);
+ Assert.False(File.Exists(path + ".partial"));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task ExportAuditPackage_WritesWorkspaceReportEvidenceAndChecksums()
+ {
+ var directory = CreateTemporaryDirectory();
+ try
+ {
+ var evidencePath = Path.Combine(directory, "sv-evidence.zip");
+ await File.WriteAllBytesAsync(evidencePath, [1, 2, 3, 4, 5, 6]);
+ var outputPath = Path.Combine(directory, "fat-sat-audit.zip");
+ var service = new FatSatWorkspaceService();
+ var document = service.CreateDefault();
+ document.ProjectName = "Bay 1 SAT";
+ document.ApplicationVersion = "1.6.19";
+ document.ApplicationCommit = "app123";
+ document.EngineRepository = "masarray/ARIEC61850";
+ document.EngineReference = "main";
+ document.EngineCommit = "0f8453182957900bc6d91287fb8177c8d9762188";
+ var sampleTest = document.TestCases.First(item => item.Area == "Sampled Values");
+ sampleTest.Result = FatSatTestResult.Review;
+ sampleTest.ActualResult = "Capture received; semantic mapping remains unresolved.";
+ sampleTest.Evidence.Add(await service.CreateEvidenceReferenceAsync(evidencePath));
+
+ var result = await service.ExportAuditPackageAsync(outputPath, document);
+
+ Assert.True(File.Exists(outputPath));
+ Assert.Matches("^[0-9a-f]{64}$", result.PackageSha256);
+ Assert.Equal(1, result.Summary.EvidenceFiles);
+ Assert.True(result.Summary.HasBlockingOutcome);
+
+ using var archive = ZipFile.OpenRead(outputPath);
+ Assert.NotNull(archive.GetEntry("workspace.json"));
+ Assert.NotNull(archive.GetEntry("report.md"));
+ Assert.NotNull(archive.GetEntry("SHA256SUMS.txt"));
+ var evidenceEntry = Assert.Single(archive.Entries.Where(entry => entry.FullName.StartsWith("evidence/", StringComparison.Ordinal)));
+ Assert.EndsWith("sv-evidence.zip", evidenceEntry.FullName, StringComparison.Ordinal);
+
+ var packagedWorkspace = await ReadEntryAsync(archive, "workspace.json");
+ using var json = JsonDocument.Parse(packagedWorkspace);
+ var packagedSourcePath = json.RootElement
+ .GetProperty("testCases")
+ .EnumerateArray()
+ .First(item => item.GetProperty("area").GetString() == "Sampled Values")
+ .GetProperty("evidence")[0]
+ .GetProperty("sourcePath")
+ .GetString();
+ Assert.StartsWith("evidence/", packagedSourcePath, StringComparison.Ordinal);
+ Assert.DoesNotContain(directory, packagedWorkspace, StringComparison.OrdinalIgnoreCase);
+
+ var report = await ReadEntryAsync(archive, "report.md");
+ Assert.Contains("REVIEW REQUIRED", report, StringComparison.Ordinal);
+ Assert.Contains("Bay 1 SAT", report, StringComparison.Ordinal);
+ Assert.Contains("0f8453182957900bc6d91287fb8177c8d9762188", report, StringComparison.Ordinal);
+
+ var checksums = await ReadEntryAsync(archive, "SHA256SUMS.txt");
+ Assert.Contains(" workspace.json", checksums, StringComparison.Ordinal);
+ Assert.Contains(" report.md", checksums, StringComparison.Ordinal);
+ Assert.Contains($" {evidenceEntry.FullName}", checksums, StringComparison.Ordinal);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task ExportAuditPackage_RejectsEvidenceChangedAfterAttachment()
+ {
+ var directory = CreateTemporaryDirectory();
+ try
+ {
+ var evidencePath = Path.Combine(directory, "capture.pcapng");
+ await File.WriteAllBytesAsync(evidencePath, [10, 20, 30]);
+ var service = new FatSatWorkspaceService();
+ var document = service.CreateDefault();
+ var testCase = document.TestCases[0];
+ testCase.Evidence.Add(await service.CreateEvidenceReferenceAsync(evidencePath));
+ await File.WriteAllBytesAsync(evidencePath, [10, 20, 31]);
+
+ var exception = await Assert.ThrowsAsync(() =>
+ service.ExportAuditPackageAsync(Path.Combine(directory, "invalid.zip"), document));
+
+ Assert.Contains("hash changed", exception.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.False(File.Exists(Path.Combine(directory, "invalid.zip")));
+ Assert.False(File.Exists(Path.Combine(directory, "invalid.zip.partial")));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task OpenAsync_RejectsUnsupportedSchemaVersion()
+ {
+ var directory = CreateTemporaryDirectory();
+ try
+ {
+ var path = Path.Combine(directory, "future.json");
+ await File.WriteAllTextAsync(path, "{\"schemaVersion\":99,\"workspaceId\":\"00000000-0000-0000-0000-000000000001\",\"testCases\":[]}");
+ var service = new FatSatWorkspaceService();
+
+ var exception = await Assert.ThrowsAsync(() => service.OpenAsync(path));
+
+ Assert.Contains("Unsupported FAT/SAT schema version", exception.Message, StringComparison.Ordinal);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ private static string CreateTemporaryDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"arsas-fat-sat-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private static async Task ReadEntryAsync(ZipArchive archive, string name)
+ {
+ var entry = archive.GetEntry(name) ?? throw new InvalidDataException($"Missing entry {name}");
+ await using var stream = entry.Open();
+ using var reader = new StreamReader(stream);
+ return await reader.ReadToEndAsync();
+ }
+}