diff --git a/IoListTestingWindow.xaml b/IoListTestingWindow.xaml
index 6b1d9e908..34f34f9c9 100644
--- a/IoListTestingWindow.xaml
+++ b/IoListTestingWindow.xaml
@@ -28,16 +28,20 @@
-
+
-
+
+
-
+
diff --git a/IoListTestingWindow.xaml.cs b/IoListTestingWindow.xaml.cs
index 834bfff55..af18a25f9 100644
--- a/IoListTestingWindow.xaml.cs
+++ b/IoListTestingWindow.xaml.cs
@@ -107,28 +107,103 @@ private void SaveProgress_Click(object sender, RoutedEventArgs e)
}
}
- private async void ExportHandover_Click(object sender, RoutedEventArgs e)
+ private async void ExportExcel_Click(object sender, RoutedEventArgs e)
{
if (Storage == null)
return;
- if (Session.IsSessionActive)
+ if (!EnsureSessionSealedForExport("Excel evidence workbook"))
+ return;
+
+ var dialog = new SaveFileDialog
{
+ Title = "Export ARSAS IO FAT result workbook",
+ Filter = "Excel workbook (*.xlsx)|*.xlsx",
+ FileName = $"{SafeFileName(Project.ProjectId)}_IO-FAT-Results_{DateTime.Now:yyyyMMdd_HHmm}.xlsx",
+ AddExtension = true,
+ DefaultExt = ".xlsx",
+ OverwritePrompt = true
+ };
+ if (dialog.ShowDialog(this) != true)
+ return;
+
+ try
+ {
+ IsEnabled = false;
+ Storage.SaveNow();
+ await IoFatExcelResultExportService.ExportAsync(
+ Storage.SourceWorkbookPath,
+ dialog.FileName,
+ Project);
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",
+ $"FAT result workbook created successfully.\n\n{dialog.FileName}\n\nThe approved source workbook was not modified.",
+ "Excel evidence exported",
MessageBoxButton.OK,
MessageBoxImage.Information);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or ArgumentException)
+ {
+ MessageBox.Show(this, ex.Message, "Excel evidence export failed", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ IsEnabled = true;
+ }
+ }
+
+ private void ExportPdf_Click(object sender, RoutedEventArgs e)
+ {
+ if (!EnsureSessionSealedForExport("PDF evidence report"))
+ return;
+
+ var dialog = new SaveFileDialog
+ {
+ Title = "Export native ARSAS IO FAT PDF report",
+ Filter = "PDF evidence report (*.pdf)|*.pdf",
+ FileName = $"{SafeFileName(Project.ProjectId)}_IO-FAT_{DateTime.Now:yyyyMMdd_HHmm}.pdf",
+ AddExtension = true,
+ DefaultExt = ".pdf",
+ OverwritePrompt = true
+ };
+ if (dialog.ShowDialog(this) != true)
return;
+
+ try
+ {
+ IsEnabled = false;
+ Storage?.SaveNow();
+ IoFatPdfReportService.Save(dialog.FileName, Project);
+ MessageBox.Show(
+ this,
+ $"Native PDF evidence report created successfully.\n\n{dialog.FileName}",
+ "PDF report exported",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
}
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or ArgumentException)
+ {
+ MessageBox.Show(this, ex.Message, "PDF report export failed", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ IsEnabled = true;
+ }
+ }
+
+ private async void ExportHandover_Click(object sender, RoutedEventArgs e)
+ {
+ if (Storage == null)
+ return;
+ if (!EnsureSessionSealedForExport("ARSAS project"))
+ return;
var dialog = new SaveFileDialog
{
- 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}",
+ Title = "Export portable ARSAS IO FAT project",
+ Filter = $"ARSAS project (*{IoFatProjectPackageService.PackageExtension})|*{IoFatProjectPackageService.PackageExtension}",
+ FileName = $"{SafeFileName(Project.ProjectId)}_{DateTime.Now:yyyyMMdd_HHmm}{IoFatProjectPackageService.PackageExtension}",
AddExtension = true,
- DefaultExt = IoTestWorkspacePersistence.PackageExtension,
+ DefaultExt = IoFatProjectPackageService.PackageExtension,
OverwritePrompt = true
};
if (dialog.ShowDialog(this) != true)
@@ -137,17 +212,20 @@ private async void ExportHandover_Click(object sender, RoutedEventArgs e)
try
{
IsEnabled = false;
- await Storage.ExportPackageAsync(dialog.FileName);
+ var exportedPath = await IoFatProjectPackageService.ExportAsync(
+ Storage,
+ Session,
+ 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",
+ $"Portable ARSAS project created successfully.\n\n{exportedPath}\n\nOpen this .arsas file on another laptop to continue the remaining FAT scope. The package also contains the native PDF report and the completed Excel result workbook.",
+ "ARSAS project exported",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException)
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or ArgumentException)
{
- MessageBox.Show(this, ex.Message, "FAT handover export failed", MessageBoxButton.OK, MessageBoxImage.Error);
+ MessageBox.Show(this, ex.Message, "ARSAS project export failed", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
@@ -155,6 +233,20 @@ private async void ExportHandover_Click(object sender, RoutedEventArgs e)
}
}
+ private bool EnsureSessionSealedForExport(string outputName)
+ {
+ if (!Session.IsSessionActive)
+ return true;
+
+ MessageBox.Show(
+ this,
+ $"Stop the active IED session before exporting the {outputName}. This seals and verifies the current evidence journal first.",
+ "Stop session before export",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ return false;
+ }
+
private void ReturnToEngineering_Click(object sender, RoutedEventArgs e)
=> Close();
diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs
index 93239220c..3b9e89a8c 100644
--- a/MainWindow.IoTesting.cs
+++ b/MainWindow.IoTesting.cs
@@ -148,7 +148,7 @@ private Border CreateIoListTestingCard()
});
content.Children.Add(new TextBlock
{
- Text = "Import the ARSAS Excel template for a new project, or open a portable handover package to continue saved progress from another laptop.",
+ Text = "Import the ARSAS Excel template for a new project, or open a portable .arsas project to continue saved progress from another laptop.",
TextWrapping = TextWrapping.Wrap,
FontSize = 13.4,
Foreground = TryFindResource("Muted") as Brush,
@@ -162,7 +162,7 @@ private Border CreateIoListTestingCard()
Brushes.White,
new Thickness(0, 0, 0, 8)));
content.Children.Add(CreateLauncherButton(
- "Open FAT Handover Package",
+ "Open ARSAS Project",
"LucideFolderOpen",
"SoftButton",
OpenIoListPackage_Click,
@@ -170,7 +170,7 @@ private Border CreateIoListTestingCard()
new Thickness(0, 0, 0, 10)));
content.Children.Add(new TextBlock
{
- Text = "Autosave · portable continuation · verified evidence · printable browser report",
+ Text = "Autosave · portable continuation · verified evidence · native PDF report",
Style = TryFindResource("Caption") as Style,
TextWrapping = TextWrapping.Wrap
});
@@ -291,15 +291,15 @@ 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 (*.*)|*.*",
+ Title = "Open ARSAS IO FAT project",
+ Filter = IoFatProjectPackageService.OpenDialogFilter,
CheckFileExists = true,
Multiselect = false
};
if (dialog.ShowDialog(this) != true)
return;
- SetStatus($"Opening FAT handover package {Path.GetFileName(dialog.FileName)}…");
+ SetStatus($"Opening ARSAS IO FAT project {Path.GetFileName(dialog.FileName)}…");
try
{
var launch = await IoTestWorkspaceBootstrapService.OpenPackageAsync(
@@ -312,11 +312,11 @@ private async void OpenIoListPackage_Click(object sender, RoutedEventArgs e)
}
catch (OperationCanceledException)
{
- SetStatus("FAT handover import cancelled.");
+ SetStatus("ARSAS IO FAT project 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");
+ ShowIoTestingFailure(ex, "ARSAS IO FAT project import failed");
}
}
diff --git a/Services/IoTesting/IoFatExcelResultExportService.cs b/Services/IoTesting/IoFatExcelResultExportService.cs
new file mode 100644
index 000000000..731bfa2de
--- /dev/null
+++ b/Services/IoTesting/IoFatExcelResultExportService.cs
@@ -0,0 +1,351 @@
+using System.Globalization;
+using System.IO.Compression;
+using System.Xml.Linq;
+using ArIED61850Tester.Models.IoTesting;
+
+namespace ArIED61850Tester.Services.IoTesting;
+
+///
+/// Writes the current IO FAT evidence back into a copy of the approved ARSAS
+/// import workbook. The source workbook is never modified in place.
+///
+public static class IoFatExcelResultExportService
+{
+ private const string SignalSheetName = "ARSAS_SIGNAL_IMPORT";
+ private const int MaxWorkbookBytes = 50 * 1024 * 1024;
+ private static readonly XNamespace SpreadsheetNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
+ private static readonly XNamespace RelationshipsNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
+ private static readonly XNamespace PackageRelationshipsNs = "http://schemas.openxmlformats.org/package/2006/relationships";
+
+ private static readonly string[] ResultHeaders =
+ {
+ "ONObservedValue",
+ "ONIEDTimestamp",
+ "ONARSASTimestamp",
+ "ONQuality",
+ "ONAcquisitionSource",
+ "ONResult",
+ "OFFObservedValue",
+ "OFFIEDTimestamp",
+ "OFFARSASTimestamp",
+ "OFFQuality",
+ "OFFAcquisitionSource",
+ "OFFResult",
+ "OverallResult",
+ "TestNotes"
+ };
+
+ public static Task ExportAsync(
+ string sourceWorkbookPath,
+ string destinationPath,
+ IoTestProject project,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(sourceWorkbookPath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
+ ArgumentNullException.ThrowIfNull(project);
+
+ return Task.Run(() => ExportCore(sourceWorkbookPath, destinationPath, project, cancellationToken), cancellationToken);
+ }
+
+ private static void ExportCore(
+ string sourceWorkbookPath,
+ string destinationPath,
+ IoTestProject project,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var source = new FileInfo(sourceWorkbookPath);
+ if (!source.Exists)
+ throw new FileNotFoundException("The approved IO FAT source workbook was not found.", sourceWorkbookPath);
+ if (source.Length > MaxWorkbookBytes)
+ throw new InvalidDataException($"The IO FAT workbook exceeds the {MaxWorkbookBytes / 1024 / 1024} MB safety limit.");
+
+ var fullDestination = Path.GetFullPath(destinationPath);
+ if (!fullDestination.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
+ fullDestination += ".xlsx";
+ if (Path.GetFullPath(sourceWorkbookPath).Equals(fullDestination, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("Choose a different output file. ARSAS will not overwrite the approved source workbook.");
+
+ Directory.CreateDirectory(Path.GetDirectoryName(fullDestination)!);
+ var temporary = fullDestination + ".tmp-" + Guid.NewGuid().ToString("N");
+ try
+ {
+ File.Copy(sourceWorkbookPath, temporary, overwrite: false);
+ using (var archive = ZipFile.Open(temporary, ZipArchiveMode.Update))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var sheetPath = ResolveSheetPath(archive, SignalSheetName);
+ var sharedStrings = ReadSharedStrings(archive);
+ var sheetDocument = LoadXml(archive, sheetPath);
+ var sheetData = sheetDocument.Root?.Element(SpreadsheetNs + "sheetData")
+ ?? throw new InvalidDataException($"Sheet '{SignalSheetName}' has no sheetData element.");
+ var rows = sheetData.Elements(SpreadsheetNs + "row").ToList();
+ if (rows.Count == 0)
+ throw new InvalidDataException($"Sheet '{SignalSheetName}' contains no rows.");
+
+ var headerRow = rows.OrderBy(RowNumber).First();
+ var headers = ReadHeaders(headerRow, sharedStrings);
+ if (!headers.TryGetValue("TestPointId", out var testPointColumn))
+ throw new InvalidDataException($"Sheet '{SignalSheetName}' is missing the TestPointId column.");
+
+ var missing = ResultHeaders.Where(header => !headers.ContainsKey(header)).ToList();
+ if (missing.Count > 0)
+ {
+ throw new InvalidDataException(
+ $"The workbook is missing result column(s): {string.Join(", ", missing)}. Use the current ARSAS FAT workbook schema before exporting evidence.");
+ }
+
+ var points = project.Ieds
+ .SelectMany(ied => ied.TestPoints)
+ .ToDictionary(point => point.TestPointId, StringComparer.OrdinalIgnoreCase);
+ var matched = 0;
+ foreach (var row in rows.Where(row => !ReferenceEquals(row, headerRow)))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var rowNumber = RowNumber(row);
+ var idCell = FindCell(row, testPointColumn, rowNumber);
+ var testPointId = idCell == null ? string.Empty : ReadCellValue(idCell, sharedStrings).Trim();
+ if (string.IsNullOrWhiteSpace(testPointId) || !points.TryGetValue(testPointId, out var point))
+ continue;
+
+ matched++;
+ var values = ResultValues(point);
+ foreach (var value in values)
+ WriteInlineString(row, headers[value.Key], rowNumber, value.Value);
+ }
+
+ if (matched != points.Count)
+ {
+ var missingCount = points.Count - matched;
+ throw new InvalidDataException(
+ $"The output workbook matched {matched} of {points.Count} test points. {missingCount} project point(s) were not found by TestPointId, so no partial result workbook was produced.");
+ }
+
+ UpdateDimension(sheetDocument, rows, headers.Values.Max());
+ ReplaceXmlEntry(archive, sheetPath, sheetDocument);
+ }
+
+ File.Move(temporary, fullDestination, true);
+ }
+ finally
+ {
+ if (File.Exists(temporary))
+ File.Delete(temporary);
+ }
+ }
+
+ private static IReadOnlyDictionary ResultValues(IoTestPointPlan point)
+ {
+ var on = point.Runtime.OnEvidence;
+ var off = point.Runtime.OffEvidence;
+ return new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ONObservedValue"] = on?.RawValue ?? string.Empty,
+ ["ONIEDTimestamp"] = Timestamp(on?.IedTimestamp),
+ ["ONARSASTimestamp"] = Timestamp(on?.CapturedAt),
+ ["ONQuality"] = on?.Quality ?? string.Empty,
+ ["ONAcquisitionSource"] = on?.AcquisitionSource ?? string.Empty,
+ ["ONResult"] = EvidenceResult(on),
+ ["OFFObservedValue"] = off?.RawValue ?? string.Empty,
+ ["OFFIEDTimestamp"] = Timestamp(off?.IedTimestamp),
+ ["OFFARSASTimestamp"] = Timestamp(off?.CapturedAt),
+ ["OFFQuality"] = off?.Quality ?? string.Empty,
+ ["OFFAcquisitionSource"] = off?.AcquisitionSource ?? string.Empty,
+ ["OFFResult"] = EvidenceResult(off),
+ ["OverallResult"] = OverallResult(point.Runtime.State),
+ ["TestNotes"] = point.Runtime.StatusReason
+ };
+ }
+
+ private static string Timestamp(DateTimeOffset? value)
+ => value?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture) ?? string.Empty;
+
+ private static string EvidenceResult(IoTestTransitionEvidence? evidence) => evidence?.Verdict switch
+ {
+ IoEvidenceVerdict.Accepted => "PASS",
+ IoEvidenceVerdict.Rejected => "FAIL",
+ IoEvidenceVerdict.Review => "REVIEW",
+ _ => string.Empty
+ };
+
+ private static string OverallResult(IoTestPointState state) => state switch
+ {
+ IoTestPointState.Passed => "PASS",
+ IoTestPointState.Failed => "FAIL",
+ IoTestPointState.Review => "REVIEW",
+ _ => "PENDING"
+ };
+
+ private static string ResolveSheetPath(ZipArchive archive, string sheetName)
+ {
+ var workbook = LoadXml(archive, "xl/workbook.xml");
+ var relationships = LoadXml(archive, "xl/_rels/workbook.xml.rels");
+ var sheet = workbook.Root?
+ .Element(SpreadsheetNs + "sheets")?
+ .Elements(SpreadsheetNs + "sheet")
+ .FirstOrDefault(item => string.Equals((string?)item.Attribute("name"), sheetName, StringComparison.OrdinalIgnoreCase));
+ if (sheet == null)
+ throw new InvalidDataException($"Required sheet '{sheetName}' was not found.");
+
+ var relationshipId = (string?)sheet.Attribute(RelationshipsNs + "id");
+ if (string.IsNullOrWhiteSpace(relationshipId))
+ throw new InvalidDataException($"Sheet '{sheetName}' has no workbook relationship.");
+ var relationship = relationships.Root?
+ .Elements(PackageRelationshipsNs + "Relationship")
+ .FirstOrDefault(item => string.Equals((string?)item.Attribute("Id"), relationshipId, StringComparison.Ordinal));
+ var target = (string?)relationship?.Attribute("Target");
+ if (string.IsNullOrWhiteSpace(target))
+ throw new InvalidDataException($"Sheet '{sheetName}' target could not be resolved.");
+ return NormalizeWorkbookTarget(target);
+ }
+
+ private static XDocument LoadXml(ZipArchive archive, string entryPath)
+ {
+ var entry = archive.GetEntry(entryPath)
+ ?? throw new InvalidDataException($"XLSX entry '{entryPath}' is missing.");
+ using var stream = entry.Open();
+ return XDocument.Load(stream, LoadOptions.PreserveWhitespace);
+ }
+
+ private static IReadOnlyList ReadSharedStrings(ZipArchive archive)
+ {
+ var entry = archive.GetEntry("xl/sharedStrings.xml");
+ if (entry == null)
+ return Array.Empty();
+ using var stream = entry.Open();
+ var document = XDocument.Load(stream, LoadOptions.None);
+ return document.Descendants(SpreadsheetNs + "si")
+ .Select(item => string.Concat(item.Descendants(SpreadsheetNs + "t").Select(text => text.Value)))
+ .ToList();
+ }
+
+ private static Dictionary ReadHeaders(XElement row, IReadOnlyList sharedStrings)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var cell in row.Elements(SpreadsheetNs + "c"))
+ {
+ var column = ColumnIndex((string?)cell.Attribute("r"));
+ var value = ReadCellValue(cell, sharedStrings).Trim();
+ if (column >= 0 && !string.IsNullOrWhiteSpace(value) && !result.ContainsKey(value))
+ result[value] = column;
+ }
+ return result;
+ }
+
+ private static string ReadCellValue(XElement cell, IReadOnlyList sharedStrings)
+ {
+ var type = ((string?)cell.Attribute("t") ?? string.Empty).Trim();
+ if (type == "inlineStr")
+ return string.Concat(cell.Descendants(SpreadsheetNs + "t").Select(text => text.Value));
+ var raw = cell.Element(SpreadsheetNs + "v")?.Value ?? string.Empty;
+ if (type == "s" && int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) &&
+ index >= 0 && index < sharedStrings.Count)
+ {
+ return sharedStrings[index];
+ }
+ if (type == "b")
+ return raw == "1" ? "true" : "false";
+ return raw;
+ }
+
+ private static XElement? FindCell(XElement row, int columnIndex, int rowNumber)
+ {
+ var reference = CellReference(columnIndex, rowNumber);
+ return row.Elements(SpreadsheetNs + "c")
+ .FirstOrDefault(cell => string.Equals((string?)cell.Attribute("r"), reference, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static void WriteInlineString(XElement row, int columnIndex, int rowNumber, string? value)
+ {
+ var reference = CellReference(columnIndex, rowNumber);
+ var cell = FindCell(row, columnIndex, rowNumber);
+ if (cell == null)
+ {
+ cell = new XElement(SpreadsheetNs + "c", new XAttribute("r", reference));
+ var next = row.Elements(SpreadsheetNs + "c")
+ .FirstOrDefault(existing => ColumnIndex((string?)existing.Attribute("r")) > columnIndex);
+ if (next == null)
+ row.Add(cell);
+ else
+ next.AddBeforeSelf(cell);
+ }
+
+ cell.Elements().Remove();
+ cell.SetAttributeValue("t", "inlineStr");
+ var text = new XElement(SpreadsheetNs + "t", value ?? string.Empty);
+ if (!string.IsNullOrEmpty(value) && (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])))
+ text.SetAttributeValue(XNamespace.Xml + "space", "preserve");
+ cell.Add(new XElement(SpreadsheetNs + "is", text));
+ }
+
+ private static void UpdateDimension(XDocument document, IReadOnlyList rows, int maximumColumn)
+ {
+ var maximumRow = rows.Count == 0 ? 1 : rows.Max(RowNumber);
+ var reference = $"A1:{ColumnName(maximumColumn)}{maximumRow}";
+ var dimension = document.Root?.Element(SpreadsheetNs + "dimension");
+ if (dimension == null)
+ {
+ dimension = new XElement(SpreadsheetNs + "dimension", new XAttribute("ref", reference));
+ document.Root?.AddFirst(dimension);
+ }
+ else
+ {
+ dimension.SetAttributeValue("ref", reference);
+ }
+ }
+
+ private static void ReplaceXmlEntry(ZipArchive archive, string path, XDocument document)
+ {
+ var old = archive.GetEntry(path) ?? throw new InvalidDataException($"XLSX entry '{path}' is missing.");
+ old.Delete();
+ var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
+ using var stream = entry.Open();
+ document.Save(stream, SaveOptions.DisableFormatting);
+ }
+
+ private static int RowNumber(XElement row)
+ => (int?)row.Attribute("r") ?? 1;
+
+ private static int ColumnIndex(string? cellReference)
+ {
+ if (string.IsNullOrWhiteSpace(cellReference))
+ return -1;
+ var index = 0;
+ var found = false;
+ foreach (var character in cellReference)
+ {
+ if (!char.IsLetter(character))
+ break;
+ found = true;
+ index = checked(index * 26 + (char.ToUpperInvariant(character) - 'A' + 1));
+ }
+ return found ? index - 1 : -1;
+ }
+
+ private static string CellReference(int columnIndex, int rowNumber)
+ => ColumnName(columnIndex) + rowNumber.ToString(CultureInfo.InvariantCulture);
+
+ private static string ColumnName(int zeroBasedIndex)
+ {
+ var value = checked(zeroBasedIndex + 1);
+ var result = string.Empty;
+ while (value > 0)
+ {
+ value--;
+ result = (char)('A' + (value % 26)) + result;
+ value /= 26;
+ }
+ return result;
+ }
+
+ private static string NormalizeWorkbookTarget(string target)
+ {
+ var normalized = target.Replace('\\', '/').TrimStart('/');
+ if (normalized.StartsWith("xl/", StringComparison.OrdinalIgnoreCase))
+ return normalized;
+ while (normalized.StartsWith("../", StringComparison.Ordinal))
+ normalized = normalized[3..];
+ return "xl/" + normalized.TrimStart('/');
+ }
+}
diff --git a/Services/IoTesting/IoFatPdfReportService.cs b/Services/IoTesting/IoFatPdfReportService.cs
new file mode 100644
index 000000000..8e7dcc235
--- /dev/null
+++ b/Services/IoTesting/IoFatPdfReportService.cs
@@ -0,0 +1,745 @@
+// Copyright 2026 Ari Sulistiono
+// SPDX-License-Identifier: Apache-2.0
+//
+// Native PDF primitives adapted from the project-owned ARIEC60870 PDF engine.
+// The IO FAT layout is purpose-built for ARSAS IEC 61850 evidence reports.
+
+using System.Globalization;
+using System.Text;
+using ArIED61850Tester.Models.IoTesting;
+
+namespace ArIED61850Tester.Services.IoTesting;
+
+///
+/// Dependency-free native PDF 1.4 writer for ARSAS IO List FAT evidence.
+///
+/// The implementation deliberately supports only the primitives needed by this
+/// report: built-in Type 1 fonts, vector rectangles and lines, wrapped text,
+/// paged tables, a cross-reference table, and document metadata. It does not
+/// use a browser, HTML conversion, printer driver, or third-party PDF package.
+///
+public static class IoFatPdfReportService
+{
+ private const float PageWidth = 842f; // A4 landscape in PDF points.
+ private const float PageHeight = 595f;
+ private const float Margin = 30f;
+ private const float HeaderBottom = 500f;
+ private const float ContentTop = 484f;
+ private const float ContentBottom = 55f;
+ private const float ContentWidth = PageWidth - (Margin * 2f);
+
+ private static readonly PdfColor BrandNavy = PdfColor.FromHex("0F172A");
+ private static readonly PdfColor BrandBlue = PdfColor.FromHex("2563EB");
+ private static readonly PdfColor SoftBlue = PdfColor.FromHex("EFF6FF");
+ private static readonly PdfColor SoftSlate = PdfColor.FromHex("F8FAFC");
+ private static readonly PdfColor Border = PdfColor.FromHex("DDE7F3");
+ private static readonly PdfColor SoftLine = PdfColor.FromHex("EEF2F7");
+ private static readonly PdfColor Muted = PdfColor.FromHex("64748B");
+ private static readonly PdfColor Ink = PdfColor.FromHex("111827");
+ private static readonly PdfColor White = PdfColor.FromHex("FFFFFF");
+ private static readonly PdfColor Pass = PdfColor.FromHex("15803D");
+ private static readonly PdfColor Attention = PdfColor.FromHex("B45309");
+ private static readonly PdfColor Fail = PdfColor.FromHex("B91C1C");
+ private static readonly PdfColor SoftPass = PdfColor.FromHex("F0FDF4");
+ private static readonly PdfColor SoftAttention = PdfColor.FromHex("FFFBEB");
+ private static readonly PdfColor SoftFail = PdfColor.FromHex("FEF2F2");
+
+ public static byte[] Generate(IoTestProject project, DateTimeOffset? generatedAt = null)
+ {
+ ArgumentNullException.ThrowIfNull(project);
+ var created = generatedAt ?? DateTimeOffset.Now;
+ var pages = new Renderer(project, created).Render();
+ return NativePdfDocument.Build(pages, project, created);
+ }
+
+ public static void Save(string fileName, IoTestProject project, DateTimeOffset? generatedAt = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
+ var bytes = Generate(project, generatedAt);
+ var fullPath = Path.GetFullPath(fileName);
+ Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
+ var temporary = fullPath + ".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, fullPath, true);
+ }
+ finally
+ {
+ if (File.Exists(temporary))
+ File.Delete(temporary);
+ }
+ }
+
+ private sealed class Renderer
+ {
+ private readonly IoTestProject _project;
+ private readonly DateTimeOffset _created;
+ private readonly List _pages = new();
+ private PdfPageBuffer _page = null!;
+ private float _cursorY;
+
+ public Renderer(IoTestProject project, DateTimeOffset created)
+ {
+ _project = project;
+ _created = created;
+ }
+
+ public IReadOnlyList Render()
+ {
+ NewPage();
+ DrawExecutiveSummary();
+
+ foreach (var ied in _project.Ieds)
+ DrawIedSection(ied);
+
+ if (_project.Ieds.Count == 0)
+ DrawEmptyProjectNotice();
+
+ var totalPages = _pages.Count;
+ for (var index = 0; index < totalPages; index++)
+ DrawPageChrome(_pages[index], index + 1, totalPages);
+
+ return _pages;
+ }
+
+ private void NewPage()
+ {
+ _page = new PdfPageBuffer(PageWidth, PageHeight);
+ _pages.Add(_page);
+ _cursorY = ContentTop;
+ }
+
+ private void Ensure(float requiredHeight)
+ {
+ if (_cursorY - requiredHeight < ContentBottom)
+ NewPage();
+ }
+
+ private void DrawPageChrome(PdfPageBuffer page, int pageNumber, int totalPages)
+ {
+ var counts = Counts(_project);
+ var tone = ResolveOverallTone(counts);
+ var toneColor = ResolveToneColor(tone);
+ var toneBackground = ResolveToneBackground(tone);
+
+ page.Line(Margin, HeaderBottom, PageWidth - Margin, HeaderBottom, Border, 0.8f);
+ page.Text(Margin, 562f, "ARSAS IO LIST TESTING", PdfFont.Bold, 7.2f, Muted);
+ page.Text(Margin, 541f, "IEC 61850 FAT Evidence Report", PdfFont.Bold, 20.2f, BrandNavy);
+ page.Text(
+ Margin,
+ 523f,
+ "Ordered OFF > ON > OFF transition evidence with IED and ARSAS timestamps.",
+ PdfFont.Regular,
+ 7.3f,
+ Muted);
+
+ const float cardWidth = 142f;
+ const float cardHeight = 58f;
+ var cardX = PageWidth - Margin - cardWidth;
+ const float cardTop = 566f;
+ page.RoundRect(cardX, cardTop, cardWidth, cardHeight, 5f, toneBackground, toneColor, 0.8f);
+ page.Text(cardX + 10f, cardTop - 15f, "PROJECT STATUS", PdfFont.Bold, 6.2f, Muted);
+ page.Text(cardX + 10f, cardTop - 35f, tone, PdfFont.Bold, 16.4f, toneColor);
+ page.Text(cardX + 10f, cardTop - 49f, Truncate(_project.ProjectId, 28), PdfFont.Regular, 5.8f, Muted);
+
+ page.Line(Margin, 42f, PageWidth - Margin, 42f, Border, 0.6f);
+ page.Text(
+ Margin,
+ 24f,
+ $"Generated {_created:yyyy-MM-dd HH:mm:ss zzz} | Project {_project.ProjectId} | Workbook SHA256 {ShortHash(_project.SourceWorkbookSha256)}",
+ PdfFont.Regular,
+ 6.2f,
+ Muted);
+ page.Text(PageWidth - Margin - 72f, 24f, $"Page {pageNumber} / {totalPages}", PdfFont.Regular, 6.2f, Muted);
+ }
+
+ private void DrawExecutiveSummary()
+ {
+ var counts = Counts(_project);
+ const float height = 104f;
+ Ensure(height + 12f);
+
+ _page.RoundRect(Margin, _cursorY, ContentWidth, height, 5f, SoftSlate, Border, 0.8f);
+ _page.Text(Margin + 13f, _cursorY - 19f, "Project Evidence Summary", PdfFont.Bold, 11.2f, BrandNavy);
+ _page.Text(Margin + 13f, _cursorY - 36f, $"{Clean(_project.ProjectName)} | {Clean(_project.ProjectId)}", PdfFont.Bold, 8.2f, Ink);
+ _page.Text(Margin + 13f, _cursorY - 51f, $"Source: {Clean(_project.SourceWorkbookName)}", PdfFont.Regular, 6.8f, Muted);
+ _page.Text(Margin + 13f, _cursorY - 64f, $"Workbook SHA-256: {Clean(_project.SourceWorkbookSha256)}", PdfFont.Mono, 5.8f, Muted);
+
+ const float metricTop = 86f;
+ const float gap = 7f;
+ var metricWidth = (ContentWidth - 26f - (gap * 5f)) / 6f;
+ var x = Margin + 13f;
+ DrawMetric(x, _cursorY - metricTop, metricWidth, "IED", _project.Ieds.Count.ToString(CultureInfo.InvariantCulture), BrandBlue, SoftBlue);
+ x += metricWidth + gap;
+ DrawMetric(x, _cursorY - metricTop, metricWidth, "SIGNALS", _project.SignalCount.ToString(CultureInfo.InvariantCulture), BrandNavy, White);
+ x += metricWidth + gap;
+ DrawMetric(x, _cursorY - metricTop, metricWidth, "PASS", counts.Passed.ToString(CultureInfo.InvariantCulture), Pass, SoftPass);
+ x += metricWidth + gap;
+ DrawMetric(x, _cursorY - metricTop, metricWidth, "REVIEW", counts.Review.ToString(CultureInfo.InvariantCulture), Attention, SoftAttention);
+ x += metricWidth + gap;
+ DrawMetric(x, _cursorY - metricTop, metricWidth, "FAIL", counts.Failed.ToString(CultureInfo.InvariantCulture), Fail, SoftFail);
+ x += metricWidth + gap;
+ DrawMetric(x, _cursorY - metricTop, metricWidth, "PENDING", counts.Pending.ToString(CultureInfo.InvariantCulture), Muted, White);
+
+ _cursorY -= height + 12f;
+ }
+
+ private void DrawMetric(float x, float top, float width, string label, string value, PdfColor color, PdfColor background)
+ {
+ _page.RoundRect(x, top, width, 29f, 4f, background, Border, 0.55f);
+ _page.Text(x + 7f, top - 11f, label, PdfFont.Bold, 5.3f, Muted);
+ _page.Text(x + 7f, top - 23f, value, PdfFont.Bold, 9.2f, color);
+ }
+
+ private void DrawIedSection(IoTestIedPlan ied)
+ {
+ Ensure(82f);
+ DrawIedHeader(ied, continued: false);
+ DrawTableHeader();
+
+ var rowNumber = 0;
+ foreach (var point in ied.TestPoints)
+ {
+ rowNumber++;
+ var cells = BuildCells(point, rowNumber);
+ var rowHeight = EstimateRowHeight(cells);
+ if (_cursorY - rowHeight < ContentBottom)
+ {
+ NewPage();
+ DrawIedHeader(ied, continued: true);
+ DrawTableHeader();
+ }
+ DrawRow(cells, rowHeight);
+ }
+
+ if (ied.TestPoints.Count == 0)
+ {
+ Ensure(34f);
+ _page.RoundRect(Margin, _cursorY, ContentWidth, 28f, 4f, SoftSlate, Border, 0.6f);
+ _page.Text(Margin + 10f, _cursorY - 18f, "No IO-list signals are available for this IED.", PdfFont.Regular, 7f, Muted);
+ _cursorY -= 38f;
+ }
+
+ _cursorY -= 11f;
+ }
+
+ private void DrawIedHeader(IoTestIedPlan ied, bool continued)
+ {
+ var title = continued ? $"{ied.IedName} (continued)" : ied.IedName;
+ var pending = Math.Max(0, ied.TestPoints.Count - ied.PassedCount - ied.ReviewCount - ied.TestPoints.Count(point => point.Runtime.State == IoTestPointState.Failed));
+ const float height = 48f;
+ _page.RoundRect(Margin, _cursorY, ContentWidth, height, 5f, White, Border, 0.7f);
+ _page.Rect(Margin, _cursorY, 4f, height, BrandBlue, BrandBlue, 0f);
+ _page.Text(Margin + 13f, _cursorY - 17f, Clean(title), PdfFont.Bold, 10.2f, BrandNavy);
+ _page.Text(
+ Margin + 13f,
+ _cursorY - 33f,
+ $"{Clean(ied.IpAddress)} | {Clean(ied.IedRole)} | {Clean(ied.Location)} | {Clean(ied.VoltageLevel)} | {Clean(ied.Switchgear)}",
+ PdfFont.Regular,
+ 6.3f,
+ Muted);
+ _page.Text(
+ PageWidth - Margin - 218f,
+ _cursorY - 18f,
+ $"{ied.TestPoints.Count} signals | {ied.PassedCount} PASS | {ied.ReviewCount} review | {pending} pending",
+ PdfFont.Bold,
+ 6.1f,
+ BrandBlue);
+ _cursorY -= height + 7f;
+ }
+
+ private void DrawTableHeader()
+ {
+ Ensure(22f);
+ var widths = ColumnWidths();
+ var headers = new[]
+ {
+ "#", "Signal", "IEC 61850 reference", "Expected ON / OFF", "ON evidence", "OFF evidence", "Result", "Reason"
+ };
+ var x = Margin;
+ const float height = 19f;
+ for (var index = 0; index < headers.Length; index++)
+ {
+ _page.Rect(x, _cursorY, widths[index], height, SoftBlue, Border, 0.45f);
+ _page.Text(x + 4f, _cursorY - 12.5f, headers[index], PdfFont.Bold, 5.55f, BrandBlue);
+ x += widths[index];
+ }
+ _cursorY -= height;
+ }
+
+ private void DrawRow(IReadOnlyList cells, float rowHeight)
+ {
+ var widths = ColumnWidths();
+ var x = Margin;
+ for (var index = 0; index < cells.Count; index++)
+ {
+ var cell = cells[index];
+ _page.Rect(x, _cursorY, widths[index], rowHeight, White, SoftLine, 0.35f);
+ var lines = WrapText(cell.Text, widths[index] - 8f, cell.FontSize, cell.MaxLines);
+ var y = _cursorY - 8.5f;
+ foreach (var line in lines)
+ {
+ _page.Text(x + 4f, y, line, cell.Font, cell.FontSize, cell.Color);
+ y -= cell.FontSize + 1.35f;
+ }
+ x += widths[index];
+ }
+ _cursorY -= rowHeight;
+ }
+
+ private static ReportCell[] BuildCells(IoTestPointPlan point, int rowNumber)
+ {
+ var stateColor = point.Runtime.State switch
+ {
+ IoTestPointState.Passed => Pass,
+ IoTestPointState.Failed => Fail,
+ IoTestPointState.Review => Attention,
+ _ => Muted
+ };
+
+ return new[]
+ {
+ new ReportCell(rowNumber.ToString(CultureInfo.InvariantCulture), PdfFont.Mono, 5.35f, Ink, 1),
+ new ReportCell(point.SignalName, PdfFont.Bold, 5.65f, Ink, 3),
+ new ReportCell(point.ObjectReference, PdfFont.Mono, 5.15f, Ink, 3),
+ new ReportCell($"ON {point.ExpectedOnText} ({point.ExpectedOnRaw})\nOFF {point.ExpectedOffText} ({point.ExpectedOffRaw})", PdfFont.Regular, 5.35f, Ink, 3),
+ new ReportCell(EvidenceText(point.Runtime.OnEvidence), PdfFont.Regular, 5.05f, Ink, 4),
+ new ReportCell(EvidenceText(point.Runtime.OffEvidence), PdfFont.Regular, 5.05f, Ink, 4),
+ new ReportCell(point.Runtime.State.ToString().ToUpperInvariant(), PdfFont.Bold, 5.45f, stateColor, 2),
+ new ReportCell(point.Runtime.StatusReason, PdfFont.Regular, 5.2f, Ink, 3)
+ };
+ }
+
+ private static float EstimateRowHeight(IReadOnlyList cells)
+ {
+ var widths = ColumnWidths();
+ var maximum = 1;
+ for (var index = 0; index < cells.Count; index++)
+ {
+ var lineCount = WrapText(cells[index].Text, widths[index] - 8f, cells[index].FontSize, cells[index].MaxLines).Count;
+ maximum = Math.Max(maximum, lineCount);
+ }
+ return Math.Max(18f, 8f + (maximum * 7.15f));
+ }
+
+ private static float[] ColumnWidths()
+ => new[] { 24f, 108f, 165f, 78f, 119f, 119f, 52f, 117f };
+
+ private void DrawEmptyProjectNotice()
+ {
+ Ensure(48f);
+ _page.RoundRect(Margin, _cursorY, ContentWidth, 42f, 5f, SoftAttention, Border, 0.7f);
+ _page.Text(Margin + 12f, _cursorY - 25f, "No IED test plan is present in this project.", PdfFont.Bold, 9f, Attention);
+ _cursorY -= 52f;
+ }
+ }
+
+ private sealed record ReportCell(string Text, PdfFont Font, float FontSize, PdfColor Color, int MaxLines);
+
+ private sealed class PdfPageBuffer
+ {
+ private readonly StringBuilder _operations = new();
+
+ public PdfPageBuffer(float width, float height)
+ {
+ Width = width;
+ Height = height;
+ }
+
+ public float Width { get; }
+ public float Height { get; }
+ public string Content => _operations.ToString();
+
+ public void Text(float x, float baselineY, string text, PdfFont font, float size, PdfColor color)
+ {
+ var safe = SanitizePdfText(text);
+ if (safe.Length == 0)
+ return;
+
+ _operations.Append("BT ")
+ .Append(color.FillOperation()).Append(' ')
+ .Append('/').Append(font.ResourceName()).Append(' ').Append(Number(size)).Append(" Tf ")
+ .Append("1 0 0 1 ").Append(Number(x)).Append(' ').Append(Number(baselineY)).Append(" Tm ")
+ .Append('(').Append(EscapeLiteral(safe)).Append(") Tj ET\n");
+ }
+
+ public void Line(float x1, float y1, float x2, float y2, PdfColor stroke, float width)
+ {
+ _operations.Append(Number(width)).Append(" w ")
+ .Append(stroke.StrokeOperation()).Append(' ')
+ .Append(Number(x1)).Append(' ').Append(Number(y1)).Append(" m ")
+ .Append(Number(x2)).Append(' ').Append(Number(y2)).Append(" l S\n");
+ }
+
+ public void Rect(float x, float top, float width, float height, PdfColor fill, PdfColor stroke, float lineWidth)
+ {
+ var y = top - height;
+ if (lineWidth <= 0f || fill.Equals(stroke))
+ {
+ _operations.Append(fill.FillOperation()).Append(' ')
+ .Append(Number(x)).Append(' ').Append(Number(y)).Append(' ')
+ .Append(Number(width)).Append(' ').Append(Number(height)).Append(" re f\n");
+ return;
+ }
+
+ _operations.Append(Number(lineWidth)).Append(" w ")
+ .Append(fill.FillOperation()).Append(' ')
+ .Append(stroke.StrokeOperation()).Append(' ')
+ .Append(Number(x)).Append(' ').Append(Number(y)).Append(' ')
+ .Append(Number(width)).Append(' ').Append(Number(height)).Append(" re B\n");
+ }
+
+ public void RoundRect(float x, float top, float width, float height, float radius, PdfColor fill, PdfColor stroke, float lineWidth)
+ {
+ if (radius <= 0f)
+ {
+ Rect(x, top, width, height, fill, stroke, lineWidth);
+ return;
+ }
+
+ var y = top - height;
+ var r = Math.Min(radius, Math.Min(width, height) / 2f);
+ var c = r * 0.55228475f;
+
+ if (lineWidth > 0f)
+ _operations.Append(Number(lineWidth)).Append(" w ");
+ _operations.Append(fill.FillOperation()).Append(' ');
+ if (lineWidth > 0f)
+ _operations.Append(stroke.StrokeOperation()).Append(' ');
+
+ _operations.Append(Number(x + r)).Append(' ').Append(Number(y)).Append(" m ")
+ .Append(Number(x + width - r)).Append(' ').Append(Number(y)).Append(" l ")
+ .Append(Number(x + width - r + c)).Append(' ').Append(Number(y)).Append(' ')
+ .Append(Number(x + width)).Append(' ').Append(Number(y + r - c)).Append(' ')
+ .Append(Number(x + width)).Append(' ').Append(Number(y + r)).Append(" c ")
+ .Append(Number(x + width)).Append(' ').Append(Number(y + height - r)).Append(" l ")
+ .Append(Number(x + width)).Append(' ').Append(Number(y + height - r + c)).Append(' ')
+ .Append(Number(x + width - r + c)).Append(' ').Append(Number(y + height)).Append(' ')
+ .Append(Number(x + width - r)).Append(' ').Append(Number(y + height)).Append(" c ")
+ .Append(Number(x + r)).Append(' ').Append(Number(y + height)).Append(" l ")
+ .Append(Number(x + r - c)).Append(' ').Append(Number(y + height)).Append(' ')
+ .Append(Number(x)).Append(' ').Append(Number(y + height - r + c)).Append(' ')
+ .Append(Number(x)).Append(' ').Append(Number(y + height - r)).Append(" c ")
+ .Append(Number(x)).Append(' ').Append(Number(y + r)).Append(" l ")
+ .Append(Number(x)).Append(' ').Append(Number(y + r - c)).Append(' ')
+ .Append(Number(x + r - c)).Append(' ').Append(Number(y)).Append(' ')
+ .Append(Number(x + r)).Append(' ').Append(Number(y))
+ .Append(lineWidth > 0f ? " c B\n" : " c f\n");
+ }
+ }
+
+ private static class NativePdfDocument
+ {
+ public static byte[] Build(
+ IReadOnlyList pages,
+ IoTestProject project,
+ DateTimeOffset created)
+ {
+ if (pages.Count == 0)
+ throw new InvalidOperationException("At least one PDF page is required.");
+
+ var objects = new List();
+ int AddObject(string body)
+ {
+ objects.Add(Encoding.ASCII.GetBytes(body));
+ return objects.Count;
+ }
+
+ var catalogId = AddObject("<< /Type /Catalog /Pages 2 0 R >>");
+ var pagesId = AddObject("__PAGES__");
+ var fontRegularId = AddObject("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
+ var fontBoldId = AddObject("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>");
+ var fontMonoId = AddObject("<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>");
+ var pageIds = new List();
+
+ foreach (var page in pages)
+ {
+ var contentBytes = Encoding.ASCII.GetBytes(page.Content);
+ var contentId = AddObject(
+ $"<< /Length {contentBytes.Length.ToString(CultureInfo.InvariantCulture)} >>\nstream\n{page.Content}endstream");
+ var pageId = AddObject(
+ $"<< /Type /Page /Parent {pagesId} 0 R /MediaBox [0 0 {Number(page.Width)} {Number(page.Height)}] " +
+ $"/Resources << /Font << /F1 {fontRegularId} 0 R /F2 {fontBoldId} 0 R /F3 {fontMonoId} 0 R >> >> " +
+ $"/Contents {contentId} 0 R >>");
+ pageIds.Add(pageId);
+ }
+
+ var title = $"{project.ProjectName} - ARSAS IO FAT Evidence Report";
+ var infoId = AddObject(
+ $"<< /Title ({EscapeLiteral(SanitizePdfText(title))}) " +
+ "/Author (ARSAS) " +
+ "/Creator (ARSAS Native PDF Engine, ported from ARIEC60870) " +
+ "/Producer (ARSAS Native PDF Engine) " +
+ $"/CreationDate ({PdfDate(created)}) >>");
+
+ objects[pagesId - 1] = Encoding.ASCII.GetBytes(
+ $"<< /Type /Pages /Kids [{string.Join(" ", pageIds.Select(id => $"{id} 0 R"))}] /Count {pageIds.Count} >>");
+
+ using var stream = new MemoryStream();
+ WriteAscii(stream, "%PDF-1.4\n%ARSAS native PDF\n");
+ var offsets = new long[objects.Count + 1];
+ for (var index = 0; index < objects.Count; index++)
+ {
+ offsets[index + 1] = stream.Position;
+ WriteAscii(stream, $"{index + 1} 0 obj\n");
+ stream.Write(objects[index], 0, objects[index].Length);
+ WriteAscii(stream, "\nendobj\n");
+ }
+
+ var xrefOffset = stream.Position;
+ WriteAscii(stream, $"xref\n0 {objects.Count + 1}\n");
+ WriteAscii(stream, "0000000000 65535 f \n");
+ for (var index = 1; index < offsets.Length; index++)
+ WriteAscii(stream, offsets[index].ToString("0000000000", CultureInfo.InvariantCulture) + " 00000 n \n");
+
+ WriteAscii(
+ stream,
+ $"trailer\n<< /Size {objects.Count + 1} /Root {catalogId} 0 R /Info {infoId} 0 R >>\n" +
+ $"startxref\n{xrefOffset.ToString(CultureInfo.InvariantCulture)}\n%%EOF\n");
+ return stream.ToArray();
+ }
+
+ private static void WriteAscii(Stream stream, string text)
+ {
+ var bytes = Encoding.ASCII.GetBytes(text);
+ stream.Write(bytes, 0, bytes.Length);
+ }
+ }
+
+ private readonly record struct ProjectCounts(int Passed, int Review, int Failed, int Pending);
+
+ private static ProjectCounts Counts(IoTestProject project)
+ {
+ var points = project.Ieds.SelectMany(ied => ied.TestPoints).ToList();
+ var passed = points.Count(point => point.Runtime.State == IoTestPointState.Passed);
+ var review = points.Count(point => point.Runtime.State == IoTestPointState.Review);
+ var failed = points.Count(point => point.Runtime.State == IoTestPointState.Failed);
+ return new ProjectCounts(passed, review, failed, Math.Max(0, points.Count - passed - review - failed));
+ }
+
+ private static string ResolveOverallTone(ProjectCounts counts)
+ {
+ if (counts.Failed > 0)
+ return "FAILED";
+ if (counts.Review > 0)
+ return "REVIEW";
+ if (counts.Pending > 0)
+ return "IN PROGRESS";
+ return counts.Passed > 0 ? "PASSED" : "NOT STARTED";
+ }
+
+ private static PdfColor ResolveToneColor(string tone) => tone switch
+ {
+ "PASSED" => Pass,
+ "FAILED" => Fail,
+ "REVIEW" => Attention,
+ _ => BrandBlue
+ };
+
+ private static PdfColor ResolveToneBackground(string tone) => tone switch
+ {
+ "PASSED" => SoftPass,
+ "FAILED" => SoftFail,
+ "REVIEW" => SoftAttention,
+ _ => SoftBlue
+ };
+
+ private static string EvidenceText(IoTestTransitionEvidence? evidence)
+ {
+ if (evidence == null)
+ return "-";
+ var ied = evidence.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture) ?? "not supplied";
+ return $"IED {ied}\nARSAS {evidence.CapturedAt:yyyy-MM-dd HH:mm:ss.fff zzz}\n{evidence.RawValue} | {evidence.Quality} | {evidence.AcquisitionSource}";
+ }
+
+ private static IReadOnlyList WrapText(string? value, float width, float fontSize, int maxLines)
+ {
+ var input = (value ?? string.Empty).Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n');
+ if (string.IsNullOrWhiteSpace(input))
+ return new[] { "-" };
+
+ var charsPerLine = Math.Max(7, (int)Math.Floor(width / Math.Max(2.4f, fontSize * 0.49f)));
+ var lines = new List();
+ var truncated = false;
+
+ foreach (var paragraphValue in input.Split('\n'))
+ {
+ var paragraph = SanitizePdfText(paragraphValue);
+ if (paragraph.Length == 0)
+ paragraph = "-";
+ var words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var current = new StringBuilder();
+
+ foreach (var originalWord in words)
+ {
+ var word = originalWord;
+ while (word.Length > charsPerLine)
+ {
+ if (current.Length > 0)
+ {
+ lines.Add(current.ToString());
+ current.Clear();
+ if (lines.Count >= maxLines)
+ {
+ truncated = true;
+ break;
+ }
+ }
+ lines.Add(word[..charsPerLine]);
+ word = word[charsPerLine..];
+ if (lines.Count >= maxLines)
+ {
+ truncated = word.Length > 0;
+ break;
+ }
+ }
+ if (lines.Count >= maxLines)
+ break;
+
+ if (current.Length == 0)
+ current.Append(word);
+ else if (current.Length + 1 + word.Length <= charsPerLine)
+ current.Append(' ').Append(word);
+ else
+ {
+ lines.Add(current.ToString());
+ current.Clear().Append(word);
+ if (lines.Count >= maxLines)
+ {
+ truncated = true;
+ break;
+ }
+ }
+ }
+
+ if (lines.Count >= maxLines)
+ break;
+ if (current.Length > 0)
+ lines.Add(current.ToString());
+ if (lines.Count >= maxLines)
+ {
+ truncated = true;
+ break;
+ }
+ }
+
+ if (lines.Count == 0)
+ lines.Add("-");
+ if (lines.Count > maxLines)
+ lines = lines.Take(maxLines).ToList();
+ if (truncated && lines[^1].Length > 3)
+ lines[^1] = lines[^1][..Math.Max(0, lines[^1].Length - 3)] + "...";
+ return lines;
+ }
+
+ private static string Clean(string? value)
+ {
+ var normalized = (value ?? string.Empty)
+ .Replace("\r", " ", StringComparison.Ordinal)
+ .Replace("\n", " ", StringComparison.Ordinal)
+ .Trim();
+ return string.IsNullOrWhiteSpace(normalized) ? "-" : normalized;
+ }
+
+ private static string ShortHash(string? value)
+ {
+ var clean = Clean(value);
+ return clean.Length <= 16 ? clean : clean[..16];
+ }
+
+ private static string Truncate(string? value, int maximum)
+ {
+ var clean = Clean(value);
+ if (clean.Length <= maximum || maximum <= 3)
+ return clean;
+ return clean[..(maximum - 3)] + "...";
+ }
+
+ private static string SanitizePdfText(string? value)
+ {
+ var input = Clean(value);
+ var builder = new StringBuilder(input.Length);
+ foreach (var character in input)
+ {
+ builder.Append(character switch
+ {
+ '\u2013' or '\u2014' or '\u2212' => '-',
+ '\u2192' => '>',
+ '\u2190' => '<',
+ '\u00B7' => '|',
+ '\u00A0' => ' ',
+ >= ' ' and <= '~' => character,
+ _ => ' '
+ });
+ }
+ return builder.ToString().Trim();
+ }
+
+ private static string EscapeLiteral(string value)
+ => value.Replace("\\", "\\\\", StringComparison.Ordinal)
+ .Replace("(", "\\(", StringComparison.Ordinal)
+ .Replace(")", "\\)", StringComparison.Ordinal);
+
+ private static string PdfDate(DateTimeOffset value)
+ => "D:" + value.ToLocalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
+
+ private static string Number(float value)
+ => value.ToString("0.###", CultureInfo.InvariantCulture);
+
+ private readonly struct PdfColor : IEquatable
+ {
+ public PdfColor(float red, float green, float blue)
+ {
+ Red = red;
+ Green = green;
+ Blue = blue;
+ }
+
+ public float Red { get; }
+ public float Green { get; }
+ public float Blue { get; }
+
+ public static PdfColor FromHex(string hex)
+ {
+ var value = hex.StartsWith("#", StringComparison.Ordinal) ? hex[1..] : hex;
+ if (value.Length != 6)
+ throw new ArgumentException("PDF color must be a six-digit RGB hex value.", nameof(hex));
+ return new PdfColor(
+ Convert.ToInt32(value[..2], 16) / 255f,
+ Convert.ToInt32(value.Substring(2, 2), 16) / 255f,
+ Convert.ToInt32(value.Substring(4, 2), 16) / 255f);
+ }
+
+ public string FillOperation() => $"{Number(Red)} {Number(Green)} {Number(Blue)} rg";
+ public string StrokeOperation() => $"{Number(Red)} {Number(Green)} {Number(Blue)} RG";
+ public bool Equals(PdfColor other)
+ => Math.Abs(Red - other.Red) < 0.0001f && Math.Abs(Green - other.Green) < 0.0001f && Math.Abs(Blue - other.Blue) < 0.0001f;
+ public override bool Equals(object? obj) => obj is PdfColor other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(Red, Green, Blue);
+ }
+
+ private enum PdfFont
+ {
+ Regular,
+ Bold,
+ Mono
+ }
+
+ private static string ResourceName(this PdfFont font) => font switch
+ {
+ PdfFont.Bold => "F2",
+ PdfFont.Mono => "F3",
+ _ => "F1"
+ };
+}
diff --git a/Services/IoTesting/IoFatProjectPackageService.cs b/Services/IoTesting/IoFatProjectPackageService.cs
new file mode 100644
index 000000000..9d1a91342
--- /dev/null
+++ b/Services/IoTesting/IoFatProjectPackageService.cs
@@ -0,0 +1,330 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace ArIED61850Tester.Services.IoTesting;
+
+public static class IoFatProjectPackageService
+{
+ public const string PackageExtension = ".arsas";
+ public const string LegacyPackageExtension = ".arsas-iofat";
+ private const long MaximumPackageBytes = 500L * 1024 * 1024;
+ private const int MaximumEntries = 10_000;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true
+ };
+
+ public static string OpenDialogFilter =>
+ $"ARSAS project (*{PackageExtension})|*{PackageExtension}|" +
+ $"Legacy IO FAT package (*{LegacyPackageExtension})|*{LegacyPackageExtension}|" +
+ "All files (*.*)|*.*";
+
+ public static bool IsSupportedPackagePath(string? path)
+ => !string.IsNullOrWhiteSpace(path) &&
+ (path.EndsWith(PackageExtension, StringComparison.OrdinalIgnoreCase) ||
+ path.EndsWith(LegacyPackageExtension, StringComparison.OrdinalIgnoreCase));
+
+ public static async Task ValidateAsync(
+ string packagePath,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
+ var info = new FileInfo(packagePath);
+ if (!info.Exists)
+ throw new FileNotFoundException("The ARSAS project was not found.", packagePath);
+ if (info.Length > MaximumPackageBytes)
+ throw new InvalidDataException("The ARSAS project exceeds the 500 MB safety limit.");
+
+ using var archive = ZipFile.OpenRead(packagePath);
+ if (archive.Entries.Count > MaximumEntries)
+ throw new InvalidDataException("The ARSAS project contains too many entries.");
+
+ var manifestEntry = RequiredEntry(archive, "manifest.json");
+ var manifestBytes = await ReadEntryAsync(
+ manifestEntry,
+ 5 * 1024 * 1024,
+ cancellationToken).ConfigureAwait(false);
+ using var manifest = JsonDocument.Parse(manifestBytes);
+ var root = manifest.RootElement;
+
+ if (root.TryGetProperty("packageKind", out var kind) && kind.ValueKind == JsonValueKind.String &&
+ !string.Equals(kind.GetString(), "io-fat", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException(
+ $"This .arsas project has package kind '{kind.GetString()}' and cannot be opened in the IO List Testing workspace.");
+ }
+
+ await VerifyOptionalManifestEntryAsync(
+ archive,
+ root,
+ "reportEntry",
+ "reportSha256",
+ "native PDF report",
+ cancellationToken).ConfigureAwait(false);
+ await VerifyOptionalManifestEntryAsync(
+ archive,
+ root,
+ "resultWorkbookEntry",
+ "resultWorkbookSha256",
+ "Excel result workbook",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public static async Task ExportAsync(
+ IoTestWorkspacePersistence workspace,
+ IoTestSessionController session,
+ string destinationPath,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(workspace);
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
+ if (session.IsSessionActive)
+ {
+ throw new InvalidOperationException(
+ "Stop the active FAT session before exporting an ARSAS project so every evidence journal is sealed.");
+ }
+
+ workspace.SaveNow();
+ var snapshotBytes = await File.ReadAllBytesAsync(workspace.SnapshotPath, cancellationToken).ConfigureAwait(false);
+ var sourceBytes = await File.ReadAllBytesAsync(workspace.SourceWorkbookPath, cancellationToken).ConfigureAwait(false);
+ VerifyHash(sourceBytes, workspace.Project.SourceWorkbookSha256, "local source workbook");
+
+ var evidenceFiles = new List();
+ if (Directory.Exists(workspace.EvidenceProjectDirectory))
+ {
+ foreach (var path in Directory.EnumerateFiles(
+ workspace.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 PackageEvidence(
+ $"evidence/{Path.GetFileName(path)}",
+ HashFile(path),
+ verification.RecordCount,
+ verification.LastHash));
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var generatedAt = DateTimeOffset.Now;
+ var pdfBytes = IoFatPdfReportService.Generate(workspace.Project, generatedAt);
+ var resultWorkbookTemporary = Path.Combine(
+ Path.GetTempPath(),
+ "ARSAS",
+ "IO FAT Export",
+ Guid.NewGuid().ToString("N") + ".xlsx");
+ byte[] resultWorkbookBytes;
+ try
+ {
+ await IoFatExcelResultExportService.ExportAsync(
+ workspace.SourceWorkbookPath,
+ resultWorkbookTemporary,
+ workspace.Project,
+ cancellationToken).ConfigureAwait(false);
+ resultWorkbookBytes = await File.ReadAllBytesAsync(
+ resultWorkbookTemporary,
+ cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (File.Exists(resultWorkbookTemporary))
+ File.Delete(resultWorkbookTemporary);
+ }
+
+ const string reportEntry = "report/IO-FAT-Report.pdf";
+ const string resultWorkbookEntry = "report/IO-FAT-Results.xlsx";
+ var manifest = new PackageManifest(
+ IoTestWorkspacePersistence.PackageVersion,
+ "io-fat",
+ generatedAt.ToUniversalTime(),
+ workspace.Project.ProjectId,
+ workspace.Project.ProjectName,
+ workspace.Project.SchemaVersion,
+ "project.snapshot.json",
+ HashBytes(snapshotBytes),
+ $"source/{SafeFileName(workspace.Project.SourceWorkbookName, "source.xlsx")}",
+ workspace.Project.SourceWorkbookSha256,
+ reportEntry,
+ HashBytes(pdfBytes),
+ resultWorkbookEntry,
+ HashBytes(resultWorkbookBytes),
+ evidenceFiles);
+
+ var fullDestination = NormalizeDestination(destinationPath);
+ Directory.CreateDirectory(Path.GetDirectoryName(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, pdfBytes, cancellationToken).ConfigureAwait(false);
+ await WriteEntryAsync(archive, manifest.ResultWorkbookEntry, resultWorkbookBytes, cancellationToken).ConfigureAwait(false);
+ await WriteEntryAsync(
+ archive,
+ "README.txt",
+ Encoding.UTF8.GetBytes(BuildReadme()),
+ cancellationToken).ConfigureAwait(false);
+
+ foreach (var evidence in evidenceFiles)
+ {
+ var sourcePath = Path.Combine(
+ workspace.EvidenceProjectDirectory,
+ Path.GetFileName(evidence.Entry));
+ await WriteEntryAsync(
+ archive,
+ evidence.Entry,
+ await File.ReadAllBytesAsync(sourcePath, cancellationToken).ConfigureAwait(false),
+ cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ File.Move(temporary, fullDestination, true);
+ return fullDestination;
+ }
+ finally
+ {
+ if (File.Exists(temporary))
+ File.Delete(temporary);
+ }
+ }
+
+ private static async Task VerifyOptionalManifestEntryAsync(
+ ZipArchive archive,
+ JsonElement manifest,
+ string entryProperty,
+ string hashProperty,
+ string label,
+ CancellationToken cancellationToken)
+ {
+ if (!manifest.TryGetProperty(entryProperty, out var entryValue) || entryValue.ValueKind != JsonValueKind.String ||
+ !manifest.TryGetProperty(hashProperty, out var hashValue) || hashValue.ValueKind != JsonValueKind.String)
+ {
+ return; // Legacy package: existing importer verifies snapshot, workbook and journals.
+ }
+
+ var entryName = entryValue.GetString();
+ var expectedHash = hashValue.GetString();
+ if (string.IsNullOrWhiteSpace(entryName) || string.IsNullOrWhiteSpace(expectedHash))
+ throw new InvalidDataException($"The ARSAS project {label} manifest is incomplete.");
+ var entry = RequiredEntry(archive, entryName);
+ var bytes = await ReadEntryAsync(entry, 100 * 1024 * 1024, cancellationToken).ConfigureAwait(false);
+ VerifyHash(bytes, expectedHash, label);
+ }
+
+ private static string NormalizeDestination(string destinationPath)
+ {
+ var fullPath = Path.GetFullPath(destinationPath);
+ if (fullPath.EndsWith(PackageExtension, StringComparison.OrdinalIgnoreCase))
+ return fullPath;
+ if (fullPath.EndsWith(LegacyPackageExtension, StringComparison.OrdinalIgnoreCase))
+ return fullPath[..^LegacyPackageExtension.Length] + PackageExtension;
+ return fullPath + PackageExtension;
+ }
+
+ private static ZipArchiveEntry RequiredEntry(ZipArchive archive, string name)
+ {
+ if (string.IsNullOrWhiteSpace(name) || name.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(name))
+ throw new InvalidDataException("The ARSAS project contains an unsafe entry path.");
+ return archive.GetEntry(name.Replace('\\', '/'))
+ ?? throw new InvalidDataException($"The ARSAS project entry '{name}' is missing.");
+ }
+
+ private static async Task ReadEntryAsync(
+ ZipArchiveEntry entry,
+ long maximumBytes,
+ CancellationToken cancellationToken)
+ {
+ if (entry.Length > maximumBytes)
+ throw new InvalidDataException($"ARSAS project 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($"ARSAS project 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 SafeFileName(string? value, string fallback)
+ {
+ var name = Path.GetFileName(string.IsNullOrWhiteSpace(value) ? fallback : value.Trim());
+ var invalid = Path.GetInvalidFileNameChars().ToHashSet();
+ var sanitized = new string(name.Select(character => invalid.Contains(character) ? '_' : character).ToArray()).Trim();
+ return sanitized.Length == 0 ? fallback : sanitized;
+ }
+
+ 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 project 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 BuildReadme() =>
+ "ARSAS IO FAT portable project\r\n\r\n" +
+ "Project file extension: .arsas\r\n" +
+ "To continue testing: open this file from FAT / IO List Testing > Open ARSAS Project.\r\n" +
+ "To review or print evidence: extract report/IO-FAT-Report.pdf.\r\n" +
+ "To review results in Excel: extract report/IO-FAT-Results.xlsx.\r\n" +
+ "The PDF is generated directly by the built-in native ARSAS PDF engine ported from ARIEC60870.\r\n" +
+ "The package also contains the approved source workbook, project snapshot, and verified evidence journals.\r\n";
+
+ private sealed record PackageManifest(
+ string PackageVersion,
+ string PackageKind,
+ DateTimeOffset CreatedAtUtc,
+ string ProjectId,
+ string ProjectName,
+ string SchemaVersion,
+ string SnapshotEntry,
+ string SnapshotSha256,
+ string SourceWorkbookEntry,
+ string SourceWorkbookSha256,
+ string ReportEntry,
+ string ReportSha256,
+ string ResultWorkbookEntry,
+ string ResultWorkbookSha256,
+ List EvidenceFiles);
+
+ private sealed record PackageEvidence(
+ string Entry,
+ string Sha256,
+ long RecordCount,
+ string LastHash);
+}
diff --git a/Services/IoTesting/IoTestWorkspaceBootstrapService.cs b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs
index b8bb4aafc..0587cb099 100644
--- a/Services/IoTesting/IoTestWorkspaceBootstrapService.cs
+++ b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs
@@ -95,6 +95,8 @@ public static async Task OpenPackageAsync(
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(sessionFactory);
+ await IoFatProjectPackageService.ValidateAsync(packagePath, cancellationToken).ConfigureAwait(false);
+
IoTestSessionController? createdSession = null;
var opened = await IoTestWorkspacePersistence.ImportPackageAsync(
packagePath,
diff --git a/tests/ARSAS.Tests/IoTestPersistenceTests.cs b/tests/ARSAS.Tests/IoTestPersistenceTests.cs
index b3f904490..932c2a24e 100644
--- a/tests/ARSAS.Tests/IoTestPersistenceTests.cs
+++ b/tests/ARSAS.Tests/IoTestPersistenceTests.cs
@@ -1,5 +1,6 @@
using System.IO.Compression;
using System.Security.Cryptography;
+using System.Text;
using ArIED61850Tester.Models.IoTesting;
using ArIED61850Tester.Services.IoTesting;
@@ -86,11 +87,55 @@ public async Task LocalSnapshot_PartialOnBecomesReviewAfterContinuityIsLost()
}
[Fact]
- public async Task PortablePackage_RoundTripsProgressAndPrintableReport()
+ public void NativePdfReport_GeneratesRealPagedIoFatEvidence()
+ {
+ var project = Project(new string('a', 64));
+ CompletePass(project.Ieds[0].TestPoints[0]);
+ var bytes = IoFatPdfReportService.Generate(
+ project,
+ new DateTimeOffset(2026, 7, 28, 17, 30, 0, TimeSpan.FromHours(7)));
+ var text = Encoding.ASCII.GetString(bytes);
+
+ Assert.StartsWith("%PDF-1.4", text, StringComparison.Ordinal);
+ Assert.Contains("IEC 61850 FAT Evidence Report", text, StringComparison.Ordinal);
+ Assert.Contains("CB closed", text, StringComparison.Ordinal);
+ Assert.Contains("AA1C1F03R4ADD/GGIO6.CBClsd.stVal", text, StringComparison.Ordinal);
+ Assert.Contains("xref", text, StringComparison.Ordinal);
+ Assert.EndsWith("%%EOF\n", text, StringComparison.Ordinal);
+ Assert.True(bytes.Length > 2_000);
+ }
+
+ [Fact]
+ public async Task ExcelResultExport_WritesEvidenceIntoCopyWithoutChangingSource()
+ {
+ var root = TempDirectory();
+ var source = Path.Combine(root, "source.xlsx");
+ CreateResultWorkbook(source);
+ var sourceHash = Hash(source);
+ var project = Project(sourceHash);
+ CompletePass(project.Ieds[0].TestPoints[0]);
+ var output = Path.Combine(root, "result.xlsx");
+
+ await IoFatExcelResultExportService.ExportAsync(source, output, project);
+
+ Assert.Equal(sourceHash, Hash(source));
+ Assert.True(File.Exists(output));
+ using var archive = ZipFile.OpenRead(output);
+ using var reader = new StreamReader(archive.GetEntry("xl/worksheets/sheet1.xml")!.Open());
+ var xml = await reader.ReadToEndAsync();
+ Assert.Contains("PASS", xml, StringComparison.Ordinal);
+ Assert.Contains("2026-07-28 10:00:00.200 +00:00", xml, StringComparison.Ordinal);
+ Assert.Contains("Good", xml, StringComparison.Ordinal);
+ Assert.Contains("BRCB", xml, StringComparison.Ordinal);
+ Assert.Contains("PASS: ON and OFF transitions captured in order", xml, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ArsasProject_RoundTripsProgressAndNativeReports()
{
var root = TempDirectory();
var workbook = Path.Combine(root, "source.xlsx");
- await File.WriteAllBytesAsync(workbook, new byte[] { 10, 20, 30, 40, 50 });
+ CreateResultWorkbook(workbook);
var hash = Hash(workbook);
var project = Project(hash);
CompletePass(project.Ieds[0].TestPoints[0]);
@@ -102,23 +147,44 @@ public async Task PortablePackage_RoundTripsProgressAndPrintableReport()
workbook,
Path.Combine(root, "projects-a"),
evidenceRoot);
- var package = Path.Combine(root, "handover.arsas-iofat");
+ var package = Path.Combine(root, "handover.arsas");
using (session)
using (opened.Workspace)
{
- await opened.Workspace.ExportPackageAsync(package);
+ var exported = await IoFatProjectPackageService.ExportAsync(
+ opened.Workspace,
+ session,
+ package);
+ Assert.Equal(package, exported);
}
+ await IoFatProjectPackageService.ValidateAsync(package);
using (var archive = ZipFile.OpenRead(package))
{
Assert.NotNull(archive.GetEntry("manifest.json"));
Assert.NotNull(archive.GetEntry("project.snapshot.json"));
- Assert.NotNull(archive.GetEntry("report/IO-FAT-Report.html"));
- using var reader = new StreamReader(archive.GetEntry("report/IO-FAT-Report.html")!.Open());
- var report = await reader.ReadToEndAsync();
- Assert.Contains("ARSAS IO List FAT Evidence Report", report, StringComparison.Ordinal);
- Assert.Contains("CB closed", report, StringComparison.Ordinal);
- Assert.Contains("Passed", report, StringComparison.Ordinal);
+ var pdfEntry = archive.GetEntry("report/IO-FAT-Report.pdf");
+ Assert.NotNull(pdfEntry);
+ await using (var pdfStream = pdfEntry!.Open())
+ {
+ using var memory = new MemoryStream();
+ await pdfStream.CopyToAsync(memory);
+ var report = Encoding.ASCII.GetString(memory.ToArray());
+ Assert.StartsWith("%PDF-1.4", report, StringComparison.Ordinal);
+ Assert.Contains("CB closed", report, StringComparison.Ordinal);
+ Assert.Contains("PASSED", report, StringComparison.Ordinal);
+ }
+
+ var excelEntry = archive.GetEntry("report/IO-FAT-Results.xlsx");
+ Assert.NotNull(excelEntry);
+ await using var excelStream = excelEntry!.Open();
+ using var excelMemory = new MemoryStream();
+ await excelStream.CopyToAsync(excelMemory);
+ using var resultArchive = new ZipArchive(new MemoryStream(excelMemory.ToArray()), ZipArchiveMode.Read);
+ using var sheetReader = new StreamReader(resultArchive.GetEntry("xl/worksheets/sheet1.xml")!.Open());
+ var resultXml = await sheetReader.ReadToEndAsync();
+ Assert.Contains("PASS", resultXml, StringComparison.Ordinal);
+ Assert.Contains("BRCB", resultXml, StringComparison.Ordinal);
}
var imported = await IoTestWorkspaceBootstrapService.OpenPackageAsync(
@@ -138,11 +204,47 @@ public async Task PortablePackage_RoundTripsProgressAndPrintableReport()
}
[Fact]
- public async Task PortablePackage_RejectsTamperedSnapshot()
+ public async Task ArsasProject_LegacyExtensionRemainsReadable()
+ {
+ var root = TempDirectory();
+ var workbook = Path.Combine(root, "source.xlsx");
+ CreateResultWorkbook(workbook);
+ var project = Project(Hash(workbook));
+ CompletePass(project.Ieds[0].TestPoints[0]);
+ var evidenceRoot = Path.Combine(root, "evidence");
+ var session = Session(project, evidenceRoot);
+ var opened = await IoTestWorkspacePersistence.OpenWorkbookAsync(
+ project,
+ session,
+ workbook,
+ Path.Combine(root, "projects"),
+ evidenceRoot);
+ var modern = Path.Combine(root, "handover.arsas");
+ using (session)
+ using (opened.Workspace)
+ await IoFatProjectPackageService.ExportAsync(opened.Workspace, session, modern);
+
+ var legacy = Path.Combine(root, "handover.arsas-iofat");
+ File.Copy(modern, legacy);
+ Assert.True(IoFatProjectPackageService.IsSupportedPackagePath(modern));
+ Assert.True(IoFatProjectPackageService.IsSupportedPackagePath(legacy));
+
+ var imported = await IoTestWorkspaceBootstrapService.OpenPackageAsync(
+ legacy,
+ Path.Combine(root, "projects-import"),
+ Path.Combine(root, "evidence-import"),
+ Session);
+ using (imported.Session)
+ using (imported.Workspace)
+ Assert.Equal(IoTestPointState.Passed, imported.Project.Ieds[0].TestPoints[0].Runtime.State);
+ }
+
+ [Fact]
+ public async Task ArsasProject_RejectsTamperedSnapshot()
{
var root = TempDirectory();
var workbook = Path.Combine(root, "source.xlsx");
- await File.WriteAllBytesAsync(workbook, new byte[] { 3, 1, 4, 1, 5 });
+ CreateResultWorkbook(workbook);
var project = Project(Hash(workbook));
var session = Session(project, Path.Combine(root, "evidence"));
var opened = await IoTestWorkspacePersistence.OpenWorkbookAsync(
@@ -151,10 +253,10 @@ public async Task PortablePackage_RejectsTamperedSnapshot()
workbook,
Path.Combine(root, "projects"),
Path.Combine(root, "evidence"));
- var package = Path.Combine(root, "handover.arsas-iofat");
+ var package = Path.Combine(root, "handover.arsas");
using (session)
using (opened.Workspace)
- await opened.Workspace.ExportPackageAsync(package);
+ await IoFatProjectPackageService.ExportAsync(opened.Workspace, session, package);
using (var archive = ZipFile.Open(package, ZipArchiveMode.Update))
{
@@ -173,6 +275,38 @@ await Assert.ThrowsAsync(() =>
Session));
}
+ [Fact]
+ public async Task ArsasProject_RejectsTamperedNativeReport()
+ {
+ var root = TempDirectory();
+ var workbook = Path.Combine(root, "source.xlsx");
+ CreateResultWorkbook(workbook);
+ var project = Project(Hash(workbook));
+ var session = Session(project, Path.Combine(root, "evidence"));
+ var opened = await IoTestWorkspacePersistence.OpenWorkbookAsync(
+ project,
+ session,
+ workbook,
+ Path.Combine(root, "projects"),
+ Path.Combine(root, "evidence"));
+ var package = Path.Combine(root, "handover.arsas");
+ using (session)
+ using (opened.Workspace)
+ await IoFatProjectPackageService.ExportAsync(opened.Workspace, session, package);
+
+ using (var archive = ZipFile.Open(package, ZipArchiveMode.Update))
+ {
+ var entry = archive.GetEntry("report/IO-FAT-Report.pdf")!;
+ entry.Delete();
+ var replacement = archive.CreateEntry("report/IO-FAT-Report.pdf");
+ await using var writer = new StreamWriter(replacement.Open());
+ await writer.WriteAsync("tampered report");
+ }
+
+ await Assert.ThrowsAsync(() =>
+ IoFatProjectPackageService.ValidateAsync(package));
+ }
+
private static void CompletePass(IoTestPointPlan point)
{
var evaluator = new IoTestTransitionEvaluator();
@@ -239,6 +373,81 @@ private static IoTestProject Project(string workbookHash)
};
}
+ private static void CreateResultWorkbook(string path)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ using var archive = ZipFile.Open(path, ZipArchiveMode.Create);
+ WriteEntry(archive, "[Content_Types].xml", """
+
+
+
+
+
+
+
+ """);
+ WriteEntry(archive, "_rels/.rels", """
+
+
+
+
+ """);
+ WriteEntry(archive, "xl/workbook.xml", """
+
+
+
+
+ """);
+ WriteEntry(archive, "xl/_rels/workbook.xml.rels", """
+
+
+
+
+ """);
+
+ var headers = new[]
+ {
+ "TestPointId", "ONObservedValue", "ONIEDTimestamp", "ONARSASTimestamp", "ONQuality",
+ "ONAcquisitionSource", "ONResult", "OFFObservedValue", "OFFIEDTimestamp", "OFFARSASTimestamp",
+ "OFFQuality", "OFFAcquisitionSource", "OFFResult", "OverallResult", "TestNotes"
+ };
+ var headerCells = string.Concat(headers.Select((header, index) => InlineCell(ColumnName(index) + "1", header)));
+ var sheet = $"""
+
+
+
+
+ {headerCells}
+ {InlineCell("A2", "TP-001")}
+
+
+ """;
+ WriteEntry(archive, "xl/worksheets/sheet1.xml", sheet);
+ }
+
+ private static string InlineCell(string reference, string value)
+ => $"{value}";
+
+ private static string ColumnName(int zeroBasedIndex)
+ {
+ var value = zeroBasedIndex + 1;
+ var result = string.Empty;
+ while (value > 0)
+ {
+ value--;
+ result = (char)('A' + value % 26) + result;
+ value /= 26;
+ }
+ return result;
+ }
+
+ private static void WriteEntry(ZipArchive archive, string path, string content)
+ {
+ var entry = archive.CreateEntry(path);
+ using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false));
+ writer.Write(content.Trim());
+ }
+
private static IoTestSessionController Session(IoTestProject project, string evidenceRoot)
=> new(project, _ => null, action => action(), evidenceRoot);
diff --git a/tests/ARSAS.Tests/IoTestingUiContractTests.cs b/tests/ARSAS.Tests/IoTestingUiContractTests.cs
index 79a10622f..6dc109978 100644
--- a/tests/ARSAS.Tests/IoTestingUiContractTests.cs
+++ b/tests/ARSAS.Tests/IoTestingUiContractTests.cs
@@ -23,7 +23,7 @@ public void IoListTestingWindow_ReadOnlyRunBindingsAreExplicitlyOneWay()
}
[Fact]
- public void IoTestingLauncher_UsesFirstRunChoiceCardsInsteadOfHeaderInjection()
+ public void IoTestingLauncher_UsesArsasProjectAndNativePdfWording()
{
var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs"));
@@ -31,14 +31,16 @@ public void IoTestingLauncher_UsesFirstRunChoiceCardsInsteadOfHeaderInjection()
Assert.Contains("GENERAL IEC 61850 TESTING", source, StringComparison.Ordinal);
Assert.Contains("FAT / IO LIST TESTING", source, StringComparison.Ordinal);
Assert.Contains("Open IO List Workbook", source, StringComparison.Ordinal);
- Assert.Contains("Open FAT Handover Package", source, StringComparison.Ordinal);
- Assert.Contains("IoTestWorkspaceBootstrapService", source, StringComparison.Ordinal);
+ Assert.Contains("Open ARSAS Project", source, StringComparison.Ordinal);
+ Assert.Contains("IoFatProjectPackageService.OpenDialogFilter", source, StringComparison.Ordinal);
+ Assert.Contains("native PDF report", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("printable browser report", source, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("actionPanel.Children.Insert", source, StringComparison.Ordinal);
Assert.DoesNotContain("InstallIoListTestingLauncher", source, StringComparison.Ordinal);
}
[Fact]
- public void IoTestingWindow_ExposesAutosaveAndPortableHandoverActions()
+ public void IoTestingWindow_ExposesAutosaveExcelPdfAndArsasProjectActions()
{
var document = XDocument.Load(FindRepoFile("IoListTestingWindow.xaml"));
XNamespace presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
@@ -50,12 +52,28 @@ public void IoTestingWindow_ExposesAutosaveAndPortableHandoverActions()
.ToList();
Assert.Contains("Save Progress", buttonContents);
- Assert.Contains("Export Handover", buttonContents);
+ Assert.Contains("Export Excel", buttonContents);
+ Assert.Contains("Export PDF", buttonContents);
+ Assert.Contains("Export .arsas", buttonContents);
Assert.Contains(
document.Descendants(presentation + "TextBlock"),
text => ((string?)text.Attribute("Text"))?.Contains("Autosave enabled", StringComparison.Ordinal) == true);
}
+ [Fact]
+ public void IoFatPackageService_UsesShortExtensionAndBundlesNativeReports()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatProjectPackageService.cs"));
+
+ Assert.Contains("PackageExtension = \".arsas\"", source, StringComparison.Ordinal);
+ Assert.Contains("LegacyPackageExtension = \".arsas-iofat\"", source, StringComparison.Ordinal);
+ Assert.Contains("report/IO-FAT-Report.pdf", source, StringComparison.Ordinal);
+ Assert.Contains("report/IO-FAT-Results.xlsx", source, StringComparison.Ordinal);
+ Assert.Contains("reportSha256", source, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("resultWorkbookSha256", source, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("IO-FAT-Report.html", source, StringComparison.Ordinal);
+ }
+
private static string FindRepoFile(string relativePath)
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);