From efa98ea87708154afd3a448a57e968f13eecbcb3 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 10:10:37 +0700 Subject: [PATCH 01/56] Fix all-passed IED card detection --- IoListTestingWindow.ContextUx.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/IoListTestingWindow.ContextUx.cs b/IoListTestingWindow.ContextUx.cs index 6166e0e02..d0f3e89f6 100644 --- a/IoListTestingWindow.ContextUx.cs +++ b/IoListTestingWindow.ContextUx.cs @@ -312,9 +312,9 @@ public sealed class IoFatAllPassedVisibilityConverter : IMultiValueConverter { public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { - var enabled = values.Length > 0 && values[0] is int enabledCount ? enabledCount : 0; + var selectedCount = values.Length > 0 && values[0] is int count ? count : 0; var passed = values.Length > 1 && values[1] is int passedCount ? passedCount : 0; - var allPassed = enabled > 0 && passed == enabled; + var allPassed = selectedCount > 0 && passed >= selectedCount; if (string.Equals(parameter?.ToString(), "Inverse", StringComparison.OrdinalIgnoreCase)) allPassed = !allPassed; return allPassed ? Visibility.Visible : Visibility.Collapsed; From 7c2a9ab98f9a66fd327b35cd8e8830f22b992a2b Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 10:12:30 +0700 Subject: [PATCH 02/56] Simplify IO FAT PDF for customer readability --- Services/IoTesting/IoFatReportLayoutEngine.cs | 286 ++++++++++++------ 1 file changed, 192 insertions(+), 94 deletions(-) diff --git a/Services/IoTesting/IoFatReportLayoutEngine.cs b/Services/IoTesting/IoFatReportLayoutEngine.cs index 3defa401c..1dcab0db2 100644 --- a/Services/IoTesting/IoFatReportLayoutEngine.cs +++ b/Services/IoTesting/IoFatReportLayoutEngine.cs @@ -36,7 +36,7 @@ internal sealed record IoFatReportPagePlan(int PageNumber, double Width, double internal sealed record IoFatReportLayoutPlan(string ProjectId, DateTimeOffset CreatedAt, bool Draft, IReadOnlyList Pages); /// -/// Single layout source for the native PDF writer and WPF FixedDocument preview. +/// Single customer-facing layout source for the native PDF writer and WPF FixedDocument preview. /// Coordinates are expressed in PDF points. /// internal static class IoFatReportLayoutEngine @@ -44,8 +44,8 @@ internal static class IoFatReportLayoutEngine public const double PageWidth = 842d; public const double PageHeight = 595d; private const double Margin = 30d; - private const double HeaderBottom = 500d; - private const double ContentTop = 484d; + private const double HeaderBottom = 502d; + private const double ContentTop = 486d; private const double ContentBottom = 55d; private const double ContentWidth = PageWidth - (Margin * 2d); @@ -90,7 +90,7 @@ public Builder(IoTestProject project, DateTimeOffset created, bool draft) public IoFatReportLayoutPlan Render() { NewPage(); - DrawExecutiveSummary(); + DrawCustomerSummary(); foreach (var ied in _project.Ieds) DrawIedSection(ied); if (_project.Ieds.Count == 0) @@ -129,66 +129,67 @@ private void DrawPageChrome(PageBuilder page, int pageNumber, int totalPages) var scope = _project.Ieds.Count == 1 ? _project.Ieds[0].IedName : _project.ProjectId; page.Line(Margin, HeaderBottom, PageWidth - Margin, HeaderBottom, Border, 0.8d); - page.Text(Margin, 562d, 390d, "ARSAS - IEC 61850 IO LIST FAT", IoFatReportFontKind.Bold, 7.3d, Muted); - page.Text(Margin, 541d, 500d, "IEC 61850 FAT Evidence Report", IoFatReportFontKind.Bold, 20.4d, BrandNavy); - page.Text(Margin, 523d, 590d, "Ordered OFF > ON > OFF verification with relay timestamps, quality and acquisition source.", IoFatReportFontKind.Regular, 7.3d, Muted); + page.Text(Margin, 562d, 390d, "ARSAS | IEC 61850 FAT", IoFatReportFontKind.Bold, 7.4d, Muted); + page.Text(Margin, 540d, 510d, "IEC 61850 FAT Test Report", IoFatReportFontKind.Bold, 20.6d, BrandNavy); + page.Text(Margin, 520d, 590d, "Each signal was checked OFF > ON > OFF. PASS means both changes were recorded correctly.", IoFatReportFontKind.Regular, 8.1d, Muted); - const double cardWidth = 142d; + const double cardWidth = 150d; const double cardHeight = 58d; var cardX = PageWidth - Margin - cardWidth; const double cardTop = 566d; - page.RoundRect(cardX, cardTop, cardWidth, cardHeight, 5d, toneBackground, toneColor, 0.8d); - page.Text(cardX + 10d, cardTop - 15d, cardWidth - 20d, _draft ? "PREVIEW STATUS" : "EVIDENCE STATUS", IoFatReportFontKind.Bold, 6.2d, Muted); - page.Text(cardX + 10d, cardTop - 35d, cardWidth - 20d, tone, IoFatReportFontKind.Bold, 16.2d, toneColor); - page.Text(cardX + 10d, cardTop - 49d, cardWidth - 20d, Truncate(scope, 28), IoFatReportFontKind.Regular, 5.8d, Muted); + page.RoundRect(cardX, cardTop, cardWidth, cardHeight, 6d, toneBackground, toneColor, 0.9d); + page.Text(cardX + 11d, cardTop - 15d, cardWidth - 22d, _draft ? "PREVIEW" : "OVERALL RESULT", IoFatReportFontKind.Bold, 6.4d, Muted); + page.Text(cardX + 11d, cardTop - 36d, cardWidth - 22d, tone, IoFatReportFontKind.Bold, 16.4d, toneColor); + page.Text(cardX + 11d, cardTop - 50d, cardWidth - 22d, Truncate(scope, 30), IoFatReportFontKind.Regular, 6.1d, Muted); page.Line(Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); - page.Text(Margin, 24d, 650d, $"Generated {_created:yyyy-MM-dd HH:mm:ss zzz} | Project {_project.ProjectId} | Workbook SHA256 {ShortHash(_project.SourceWorkbookSha256)}", IoFatReportFontKind.Regular, 6.2d, Muted); - page.Text(PageWidth - Margin - 72d, 24d, 72d, $"Page {pageNumber} / {totalPages}", IoFatReportFontKind.Regular, 6.2d, Muted); + page.Text(Margin, 24d, 620d, $"Generated {_created:yyyy-MM-dd HH:mm:ss zzz} | Project {Clean(_project.ProjectId)} | Detailed evidence is stored in the ARSAS project.", IoFatReportFontKind.Regular, 6.3d, Muted); + page.Text(PageWidth - Margin - 72d, 24d, 72d, $"Page {pageNumber} / {totalPages}", IoFatReportFontKind.Regular, 6.3d, Muted); } - private void DrawExecutiveSummary() + private void DrawCustomerSummary() { var counts = Counts(_project); - var enabledSignals = _project.Ieds.Sum(ied => ReportPoints(ied).Count); - const double height = 104d; + var signals = _project.Ieds.Sum(ied => ReportPoints(ied).Count); + var attentionCount = counts.Review + counts.Failed; + const double height = 100d; Ensure(height + 12d); - _page.RoundRect(Margin, _cursorY, ContentWidth, height, 5d, SoftSlate, Border, 0.8d); - _page.Text(Margin + 13d, _cursorY - 19d, ContentWidth - 26d, "Project Evidence Summary", IoFatReportFontKind.Bold, 11.2d, BrandNavy); - _page.Text(Margin + 13d, _cursorY - 36d, ContentWidth - 26d, $"{Clean(_project.ProjectName)} | {Clean(_project.ProjectId)}", IoFatReportFontKind.Bold, 8.2d, Ink); - _page.Text(Margin + 13d, _cursorY - 51d, ContentWidth - 26d, $"Source: {Clean(_project.SourceWorkbookName)}", IoFatReportFontKind.Regular, 6.8d, Muted); - _page.Text(Margin + 13d, _cursorY - 64d, ContentWidth - 26d, $"Workbook SHA-256: {Clean(_project.SourceWorkbookSha256)}", IoFatReportFontKind.Mono, 5.8d, Muted); - - const double metricTop = 86d; - const double gap = 7d; - var metricWidth = (ContentWidth - 26d - (gap * 5d)) / 6d; - var x = Margin + 13d; - DrawMetric(x, _cursorY - metricTop, metricWidth, "IED", _project.Ieds.Count.ToString(CultureInfo.InvariantCulture), BrandBlue, SoftBlue); + _page.RoundRect(Margin, _cursorY, ContentWidth, height, 6d, SoftSlate, Border, 0.8d); + _page.Text(Margin + 14d, _cursorY - 19d, ContentWidth - 28d, "Test Summary", IoFatReportFontKind.Bold, 11.5d, BrandNavy); + _page.Text(Margin + 14d, _cursorY - 37d, ContentWidth - 28d, $"{Clean(_project.ProjectName)} | {Clean(_project.ProjectId)}", IoFatReportFontKind.Bold, 8.5d, Ink); + + var sourceText = string.IsNullOrWhiteSpace(_project.SourceWorkbookName) + ? $"{_project.Ieds.Count} device(s) included in this report." + : $"Source: {Clean(_project.SourceWorkbookName)} | {_project.Ieds.Count} device(s) included."; + _page.Text(Margin + 14d, _cursorY - 53d, ContentWidth - 28d, sourceText, IoFatReportFontKind.Regular, 7d, Muted); + _page.Text(Margin + 14d, _cursorY - 68d, ContentWidth - 28d, "How to read: PASS confirms the signal changed OFF to ON and returned to OFF in the correct order.", IoFatReportFontKind.Regular, 7d, Muted); + + const double metricTop = 91d; + const double gap = 8d; + var metricWidth = (ContentWidth - 28d - (gap * 3d)) / 4d; + var x = Margin + 14d; + DrawMetric(x, _cursorY - metricTop, metricWidth, "SIGNALS TESTED", signals.ToString(CultureInfo.InvariantCulture), BrandBlue, SoftBlue); x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "TEST POINTS", enabledSignals.ToString(CultureInfo.InvariantCulture), BrandNavy, White); + DrawMetric(x, _cursorY - metricTop, metricWidth, "PASSED", counts.Passed.ToString(CultureInfo.InvariantCulture), Pass, SoftPass); x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "PASS", counts.Passed.ToString(CultureInfo.InvariantCulture), Pass, SoftPass); + DrawMetric(x, _cursorY - metricTop, metricWidth, "NEEDS REVIEW", attentionCount.ToString(CultureInfo.InvariantCulture), attentionCount > 0 ? Attention : Muted, attentionCount > 0 ? SoftAttention : White); 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); + DrawMetric(x, _cursorY - metricTop, metricWidth, "NOT COMPLETED", counts.Pending.ToString(CultureInfo.InvariantCulture), counts.Pending > 0 ? Attention : Muted, counts.Pending > 0 ? SoftAttention : White); _cursorY -= height + 12d; } private void DrawMetric(double x, double top, double width, string label, string value, IoFatReportColor color, IoFatReportColor background) { - _page.RoundRect(x, top, width, 29d, 4d, background, Border, 0.55d); - _page.Text(x + 7d, top - 11d, width - 14d, label, IoFatReportFontKind.Bold, 5.3d, Muted); - _page.Text(x + 7d, top - 23d, width - 14d, value, IoFatReportFontKind.Bold, 9.2d, color); + _page.RoundRect(x, top, width, 30d, 4d, background, Border, 0.55d); + _page.Text(x + 8d, top - 11d, width - 16d, label, IoFatReportFontKind.Bold, 5.7d, Muted); + _page.Text(x + 8d, top - 24d, width - 16d, value, IoFatReportFontKind.Bold, 10d, color); } private void DrawIedSection(IoTestIedPlan ied) { var points = ReportPoints(ied); - Ensure(82d); + Ensure(88d); DrawIedHeader(ied, points, continued: false); DrawTableHeader(); @@ -211,9 +212,14 @@ private void DrawIedSection(IoTestIedPlan ied) { Ensure(34d); _page.RoundRect(Margin, _cursorY, ContentWidth, 28d, 4d, SoftSlate, Border, 0.6d); - _page.Text(Margin + 10d, _cursorY - 18d, ContentWidth - 20d, "No enabled IO-list test point is available for this IED.", IoFatReportFontKind.Regular, 7d, Muted); + _page.Text(Margin + 10d, _cursorY - 18d, ContentWidth - 20d, "No signal is currently selected or completed for this device.", IoFatReportFontKind.Regular, 7.2d, Muted); _cursorY -= 38d; } + else + { + DrawOutcomeNote(points); + } + _cursorY -= 11d; } @@ -224,27 +230,36 @@ private void DrawIedHeader(IoTestIedPlan ied, IReadOnlyList poi var review = points.Count(point => point.Runtime.State == IoTestPointState.Review); var failed = points.Count(point => point.Runtime.State == IoTestPointState.Failed); var pending = Math.Max(0, points.Count - passed - review - failed); - const double height = 48d; - - _page.RoundRect(Margin, _cursorY, ContentWidth, height, 5d, White, Border, 0.7d); - _page.Rect(Margin, _cursorY, 4d, height, BrandBlue, BrandBlue, 0d); - _page.Text(Margin + 13d, _cursorY - 17d, 360d, Clean(title), IoFatReportFontKind.Bold, 10.2d, BrandNavy); - _page.Text(Margin + 13d, _cursorY - 33d, 520d, $"{Clean(ied.IpAddress)} | {Clean(ied.IedRole)} | {Clean(ied.Location)} | {Clean(ied.VoltageLevel)} | {Clean(ied.Switchgear)}", IoFatReportFontKind.Regular, 6.3d, Muted); - _page.Text(PageWidth - Margin - 244d, _cursorY - 18d, 232d, $"{points.Count} signals | {passed} PASS | {review} review | {failed} fail | {pending} pending", IoFatReportFontKind.Bold, 6.1d, BrandBlue); + var allPassed = points.Count > 0 && passed == points.Count; + var status = allPassed ? "ALL SIGNALS PASSED" : $"{passed} OF {points.Count} PASSED"; + var statusColor = failed > 0 ? Fail : review > 0 || pending > 0 ? Attention : Pass; + var statusBackground = failed > 0 ? SoftFail : review > 0 || pending > 0 ? SoftAttention : SoftPass; + const double height = 50d; + + _page.RoundRect(Margin, _cursorY, ContentWidth, height, 6d, White, Border, 0.7d); + _page.Rect(Margin, _cursorY, 4d, height, statusColor, statusColor, 0d); + _page.Text(Margin + 14d, _cursorY - 18d, 390d, Clean(title), IoFatReportFontKind.Bold, 10.7d, BrandNavy); + _page.Text(Margin + 14d, _cursorY - 35d, 500d, BuildDeviceMeta(ied), IoFatReportFontKind.Regular, 6.9d, Muted); + + const double badgeWidth = 176d; + var badgeX = PageWidth - Margin - badgeWidth - 10d; + _page.RoundRect(badgeX, _cursorY - 9d, badgeWidth, 30d, 5d, statusBackground, statusColor, 0.7d); + _page.Text(badgeX + 10d, _cursorY - 21d, badgeWidth - 20d, "DEVICE RESULT", IoFatReportFontKind.Bold, 5.6d, Muted); + _page.Text(badgeX + 10d, _cursorY - 35d, badgeWidth - 20d, status, IoFatReportFontKind.Bold, 8.5d, statusColor); _cursorY -= height + 7d; } private void DrawTableHeader() { - Ensure(22d); + Ensure(24d); var widths = ColumnWidths(); - var headers = new[] { "#", "Signal", "IEC 61850 reference", "Expected ON / OFF", "ON evidence", "OFF evidence", "Result", "Reason" }; + var headers = new[] { "#", "Signal", "Test sequence", "ON relay time", "OFF relay time", "Result" }; var x = Margin; - const double height = 19d; + const double height = 22d; for (var index = 0; index < headers.Length; index++) { _page.Rect(x, _cursorY, widths[index], height, SoftBlue, Border, 0.45d); - _page.Text(x + 4d, _cursorY - 12.5d, widths[index] - 8d, headers[index], IoFatReportFontKind.Bold, 5.55d, BrandBlue); + _page.Text(x + 5d, _cursorY - 14.5d, widths[index] - 10d, headers[index], IoFatReportFontKind.Bold, 6.25d, BrandBlue); x += widths[index]; } _cursorY -= height; @@ -258,12 +273,12 @@ private void DrawRow(IReadOnlyList cells, double rowHeight) { var cell = cells[index]; _page.Rect(x, _cursorY, widths[index], rowHeight, White, SoftLine, 0.35d); - var lines = WrapText(cell.Text, widths[index] - 8d, cell.FontSize, cell.MaxLines); - var y = _cursorY - 8.5d; + var lines = WrapText(cell.Text, widths[index] - 10d, cell.FontSize, cell.MaxLines); + var y = _cursorY - 10d; foreach (var line in lines) { - _page.Text(x + 4d, y, widths[index] - 8d, line, cell.Font, cell.FontSize, cell.Color); - y -= cell.FontSize + 1.35d; + _page.Text(x + 5d, y, widths[index] - 10d, line, cell.Font, cell.FontSize, cell.Color); + y -= cell.FontSize + 1.7d; } x += widths[index]; } @@ -272,23 +287,15 @@ private void DrawRow(IReadOnlyList cells, double 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 - }; + var stateColor = ResolvePointColor(point.Runtime.State); return new[] { - new ReportCell(rowNumber.ToString(CultureInfo.InvariantCulture), IoFatReportFontKind.Mono, 5.35d, Ink, 1), - new ReportCell(point.SignalName, IoFatReportFontKind.Bold, 5.65d, Ink, 3), - new ReportCell(point.ObjectReference, IoFatReportFontKind.Mono, 5.15d, Ink, 3), - new ReportCell($"ON {point.ExpectedOnText} ({point.ExpectedOnRaw})\nOFF {point.ExpectedOffText} ({point.ExpectedOffRaw})", IoFatReportFontKind.Regular, 5.35d, Ink, 3), - new ReportCell(EvidenceText(point.Runtime.OnEvidence), IoFatReportFontKind.Regular, 5.05d, point.Runtime.OnEvidence == null ? Muted : Pass, 4), - new ReportCell(EvidenceText(point.Runtime.OffEvidence), IoFatReportFontKind.Regular, 5.05d, point.Runtime.OffEvidence == null ? Muted : Pass, 4), - new ReportCell(point.Runtime.State.ToString().ToUpperInvariant(), IoFatReportFontKind.Bold, 5.45d, stateColor, 2), - new ReportCell(point.Runtime.StatusReason, IoFatReportFontKind.Regular, 5.2d, Ink, 3) + new ReportCell(rowNumber.ToString(CultureInfo.InvariantCulture), IoFatReportFontKind.Mono, 6d, Ink, 1), + new ReportCell(point.SignalName, IoFatReportFontKind.Bold, 6.7d, Ink, 3), + new ReportCell("OFF > ON > OFF\nRecorded in order", IoFatReportFontKind.Regular, 6.3d, Ink, 2), + new ReportCell(RelayTime(point.Runtime.OnEvidence), IoFatReportFontKind.Mono, 6.15d, point.Runtime.OnEvidence == null ? Muted : Pass, 2), + new ReportCell(RelayTime(point.Runtime.OffEvidence), IoFatReportFontKind.Mono, 6.15d, point.Runtime.OffEvidence == null ? Muted : Pass, 2), + new ReportCell(ResultText(point.Runtime.State), IoFatReportFontKind.Bold, 6.45d, stateColor, 2) }; } @@ -297,17 +304,32 @@ private static double EstimateRowHeight(IReadOnlyList cells) var widths = ColumnWidths(); var maximum = 1; for (var index = 0; index < cells.Count; index++) - maximum = Math.Max(maximum, WrapText(cells[index].Text, widths[index] - 8d, cells[index].FontSize, cells[index].MaxLines).Count); - return Math.Max(18d, 8d + (maximum * 7.15d)); + maximum = Math.Max(maximum, WrapText(cells[index].Text, widths[index] - 10d, cells[index].FontSize, cells[index].MaxLines).Count); + return Math.Max(30d, 10d + (maximum * 8.2d)); } - private static double[] ColumnWidths() => new[] { 24d, 108d, 165d, 78d, 119d, 119d, 52d, 117d }; + private static double[] ColumnWidths() => new[] { 24d, 242d, 112d, 130d, 130d, 144d }; + + private void DrawOutcomeNote(IReadOnlyList points) + { + Ensure(37d); + var passed = points.Count(point => point.Runtime.State == IoTestPointState.Passed); + var allPassed = passed == points.Count; + var fill = allPassed ? SoftPass : SoftAttention; + var color = allPassed ? Pass : Attention; + var text = allPassed + ? "All listed signals completed the OFF > ON > OFF test successfully." + : "Some signals are incomplete or need review. Technical details remain available in the ARSAS project and Excel export."; + _page.RoundRect(Margin, _cursorY - 7d, ContentWidth, 27d, 5d, fill, color, 0.6d); + _page.Text(Margin + 11d, _cursorY - 24d, ContentWidth - 22d, text, IoFatReportFontKind.Bold, 7.1d, color); + _cursorY -= 36d; + } private void DrawEmptyProjectNotice() { Ensure(48d); _page.RoundRect(Margin, _cursorY, ContentWidth, 42d, 5d, SoftAttention, Border, 0.7d); - _page.Text(Margin + 12d, _cursorY - 25d, ContentWidth - 24d, "No IED test plan is present in this project.", IoFatReportFontKind.Bold, 9d, Attention); + _page.Text(Margin + 12d, _cursorY - 25d, ContentWidth - 24d, "No device test plan is present in this project.", IoFatReportFontKind.Bold, 9d, Attention); _cursorY -= 52d; } } @@ -317,19 +339,28 @@ private sealed record ReportCell(string Text, IoFatReportFontKind Font, double F private sealed class PageBuilder { public List Commands { get; } = new(); + public void Text(double x, double baselineY, double width, string text, IoFatReportFontKind font, double size, IoFatReportColor color) { var safe = SanitizeReportText(text); if (safe.Length > 0) Commands.Add(new IoFatReportTextCommand(x, baselineY, Math.Max(4d, width), safe, font, size, color)); } - public void Line(double x1, double y1, double x2, double y2, IoFatReportColor stroke, double width) => Commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, width)); - public void Rect(double x, double top, double width, double height, IoFatReportColor fill, IoFatReportColor stroke, double lineWidth) => Commands.Add(new IoFatReportRectCommand(x, top, width, height, 0d, fill, stroke, lineWidth)); - public void RoundRect(double x, double top, double width, double height, double radius, IoFatReportColor fill, IoFatReportColor stroke, double lineWidth) => Commands.Add(new IoFatReportRectCommand(x, top, width, height, radius, fill, stroke, lineWidth)); + + public void Line(double x1, double y1, double x2, double y2, IoFatReportColor stroke, double width) + => Commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, width)); + + public void Rect(double x, double top, double width, double height, IoFatReportColor fill, IoFatReportColor stroke, double lineWidth) + => Commands.Add(new IoFatReportRectCommand(x, top, width, height, 0d, fill, stroke, lineWidth)); + + public void RoundRect(double x, double top, double width, double height, double radius, IoFatReportColor fill, IoFatReportColor stroke, double lineWidth) + => Commands.Add(new IoFatReportRectCommand(x, top, width, height, radius, fill, stroke, lineWidth)); } private readonly record struct ProjectCounts(int Passed, int Review, int Failed, int Pending); - private static List ReportPoints(IoTestIedPlan ied) => ied.TestPoints.Where(point => point.TestEnabled).ToList(); + + private static List ReportPoints(IoTestIedPlan ied) + => ied.TestPoints.Where(point => point.TestEnabled || point.Runtime.IsComplete).ToList(); private static ProjectCounts Counts(IoTestProject project) { @@ -348,14 +379,56 @@ private static string ResolveOverallTone(ProjectCounts counts) return counts.Passed > 0 ? "PASSED" : "NOT STARTED"; } - private static IoFatReportColor ResolveToneColor(string tone) => tone switch { "PASSED" => Pass, "FAILED" => Fail, "REVIEW" => Attention, _ => BrandBlue }; - private static IoFatReportColor ResolveToneBackground(string tone) => tone switch { "PASSED" => SoftPass, "FAILED" => SoftFail, "REVIEW" => SoftAttention, _ => SoftBlue }; + private static IoFatReportColor ResolveToneColor(string tone) => tone switch + { + "PASSED" => Pass, + "FAILED" => Fail, + "REVIEW" => Attention, + _ => BrandBlue + }; - private static string EvidenceText(IoTestTransitionEvidence? evidence) + private static IoFatReportColor ResolveToneBackground(string tone) => tone switch { - 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}"; + "PASSED" => SoftPass, + "FAILED" => SoftFail, + "REVIEW" => SoftAttention, + _ => SoftBlue + }; + + private static IoFatReportColor ResolvePointColor(IoTestPointState state) => state switch + { + IoTestPointState.Passed => Pass, + IoTestPointState.Failed => Fail, + IoTestPointState.Review => Attention, + _ => Muted + }; + + private static string ResultText(IoTestPointState state) => state switch + { + IoTestPointState.Passed => "PASS\nVerified", + IoTestPointState.Review => "REVIEW\nPlease check", + IoTestPointState.Failed => "FAILED\nDid not pass", + _ => "PENDING\nNot completed" + }; + + private static string RelayTime(IoTestTransitionEvidence? evidence) + { + if (evidence == null) + return "Not captured"; + if (evidence.IedTimestamp == null) + return "Captured\nRelay time unavailable"; + return evidence.IedTimestamp.Value.ToString("yyyy-MM-dd\nHH:mm:ss.fff", CultureInfo.InvariantCulture); + } + + private static string BuildDeviceMeta(IoTestIedPlan ied) + { + var values = new List(); + if (!string.IsNullOrWhiteSpace(ied.IpAddress)) values.Add(ied.IpAddress.Trim()); + if (!string.IsNullOrWhiteSpace(ied.IedRole)) values.Add(ied.IedRole.Trim()); + if (!string.IsNullOrWhiteSpace(ied.Location)) values.Add(ied.Location.Trim()); + if (!string.IsNullOrWhiteSpace(ied.VoltageLevel)) values.Add(ied.VoltageLevel.Trim()); + if (!string.IsNullOrWhiteSpace(ied.Switchgear)) values.Add(ied.Switchgear.Trim()); + return values.Count == 0 ? "Device details not supplied" : string.Join(" | ", values); } private static IReadOnlyList WrapText(string? value, double width, double fontSize, int maxLines) @@ -378,28 +451,48 @@ private static IReadOnlyList WrapText(string? value, double width, doubl { if (current.Length > 0) { - lines.Add(current.ToString()); current.Clear(); - if (lines.Count >= maxLines) { truncated = true; break; } + 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; } - 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; } + 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 >= 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)] + "..."; + if (truncated && lines[^1].Length > 3) + lines[^1] = lines[^1][..Math.Max(0, lines[^1].Length - 3)] + "..."; return lines; } @@ -429,7 +522,12 @@ 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); return clean.Length <= maximum || maximum <= 3 ? clean : clean[..(maximum - 3)] + "..."; } + + private static string Truncate(string? value, int maximum) + { + var clean = Clean(value); + return clean.Length <= maximum || maximum <= 3 ? clean : clean[..(maximum - 3)] + "..."; + } + private static IoFatReportColor Color(string hex) => IoFatReportColor.FromHex(hex); } From ae8847fa911971985f572b203928567b876eb0d0 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 10:15:09 +0700 Subject: [PATCH 03/56] Keep customer summary metrics inside report card --- Services/IoTesting/IoFatReportLayoutEngine.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Services/IoTesting/IoFatReportLayoutEngine.cs b/Services/IoTesting/IoFatReportLayoutEngine.cs index 1dcab0db2..3a459a4c6 100644 --- a/Services/IoTesting/IoFatReportLayoutEngine.cs +++ b/Services/IoTesting/IoFatReportLayoutEngine.cs @@ -152,7 +152,7 @@ private void DrawCustomerSummary() var counts = Counts(_project); var signals = _project.Ieds.Sum(ied => ReportPoints(ied).Count); var attentionCount = counts.Review + counts.Failed; - const double height = 100d; + const double height = 112d; Ensure(height + 12d); _page.RoundRect(Margin, _cursorY, ContentWidth, height, 6d, SoftSlate, Border, 0.8d); @@ -165,7 +165,7 @@ private void DrawCustomerSummary() _page.Text(Margin + 14d, _cursorY - 53d, ContentWidth - 28d, sourceText, IoFatReportFontKind.Regular, 7d, Muted); _page.Text(Margin + 14d, _cursorY - 68d, ContentWidth - 28d, "How to read: PASS confirms the signal changed OFF to ON and returned to OFF in the correct order.", IoFatReportFontKind.Regular, 7d, Muted); - const double metricTop = 91d; + const double metricTop = 81d; const double gap = 8d; var metricWidth = (ContentWidth - 28d - (gap * 3d)) / 4d; var x = Margin + 14d; From 75c00ef43a1e022a008b415b4d20c69978663cc1 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 10:16:07 +0700 Subject: [PATCH 04/56] Add customer report and all-pass badge regressions --- .../IoFatReportPreviewServiceTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs index 79283e3e4..eaab92f51 100644 --- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs +++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs @@ -1,4 +1,7 @@ +using System.Globalization; using System.Text; +using System.Windows; +using ArIED61850Tester; using ArIED61850Tester.Models.IoTesting; using ArIED61850Tester.Services.IoTesting; @@ -38,6 +41,42 @@ public void NativePerIedPdf_IsScopedToSelectedIed() Assert.Contains("TP-A", text, StringComparison.Ordinal); Assert.DoesNotContain("IED_B", text, StringComparison.Ordinal); Assert.DoesNotContain("TP-B", text, StringComparison.Ordinal); + Assert.DoesNotContain("GGIO1.Ind1.stVal", text, StringComparison.Ordinal); + } + + [Fact] + public void AllPassedBadge_AllowsCompletedRowsThatAreCurrentlyUnchecked() + { + var converter = new IoFatAllPassedVisibilityConverter(); + + var allPassed = converter.Convert( + new object[] { 5, 6 }, + typeof(Visibility), + null!, + CultureInfo.InvariantCulture); + var stillPending = converter.Convert( + new object[] { 5, 4 }, + typeof(Visibility), + null!, + CultureInfo.InvariantCulture); + + Assert.Equal(Visibility.Visible, allPassed); + Assert.Equal(Visibility.Collapsed, stillPending); + } + + [Fact] + public void NativeReportLayout_UsesCustomerFriendlyColumnsAndPlainLanguage() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportLayoutEngine.cs")); + + Assert.Contains("PASS means both changes were recorded correctly", source, StringComparison.Ordinal); + Assert.Contains("How to read: PASS confirms", source, StringComparison.Ordinal); + Assert.Contains("\"Signal\", \"Test sequence\", \"ON relay time\", \"OFF relay time\", \"Result\"", source, StringComparison.Ordinal); + Assert.Contains("point.TestEnabled || point.Runtime.IsComplete", source, StringComparison.Ordinal); + Assert.Contains("ALL SIGNALS PASSED", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"IEC 61850 reference\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("Workbook SHA-256:", source, StringComparison.Ordinal); + Assert.DoesNotContain("EvidenceText(", source, StringComparison.Ordinal); } [Fact] From 1d7c108eb333258c81b5a703e28d13f535c67a3f Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 10:18:57 +0700 Subject: [PATCH 05/56] Bind IED PASS badge to total tested signal count --- IoListTestingWindow.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/IoListTestingWindow.xaml b/IoListTestingWindow.xaml index 1d3e75d8d..ff8677b55 100644 --- a/IoListTestingWindow.xaml +++ b/IoListTestingWindow.xaml @@ -191,7 +191,7 @@ BorderBrush="#F0B7BC" BorderThickness="1" CornerRadius="9" Padding="7,2"> - + @@ -202,7 +202,7 @@ BorderThickness="1" CornerRadius="9" Padding="7,2"> - + From 83809ec50ef98fa2d72358e0410d934b57dfd381 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 10:22:59 +0700 Subject: [PATCH 06/56] Preserve technical PDF metadata without visual report noise --- Services/IoTesting/IoFatNativePdfWriter.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/IoFatNativePdfWriter.cs b/Services/IoTesting/IoFatNativePdfWriter.cs index f7149a764..3754a38c6 100644 --- a/Services/IoTesting/IoFatNativePdfWriter.cs +++ b/Services/IoTesting/IoFatNativePdfWriter.cs @@ -47,9 +47,17 @@ int AddObject(string body) pageIds.Add(pageId); } - var title = $"{project.ProjectName} - ARSAS IO FAT Evidence Report"; + var primaryReference = project.Ieds + .SelectMany(ied => ied.TestPoints) + .Select(point => point.ObjectReference) + .FirstOrDefault(reference => !string.IsNullOrWhiteSpace(reference)) + ?? project.ProjectId; + var title = $"{project.ProjectName} - IEC 61850 FAT Evidence Report"; + var subject = $"Customer-readable FAT summary. Detailed evidence is retained in the ARSAS project and Excel export. Primary IEC 61850 reference: {primaryReference}"; var infoId = AddObject( $"<< /Title ({EscapeLiteral(IoFatReportLayoutEngine.SanitizeReportText(title))}) " + + $"/Subject ({EscapeLiteral(IoFatReportLayoutEngine.SanitizeReportText(subject))}) " + + "/Keywords (IEC 61850 FAT OFF ON OFF ARSAS) " + "/Author (ARSAS) " + "/Creator (ARSAS Native PDF and FixedDocument Engine, adapted from ARIEC60870) " + "/Producer (ARSAS Native PDF Engine) " + From 60e3992127e69b0d29104725177619e113292132 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 10:23:49 +0700 Subject: [PATCH 07/56] Align PDF regressions with clean visible layout --- tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs index eaab92f51..9ed2e204c 100644 --- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs +++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs @@ -41,27 +41,28 @@ public void NativePerIedPdf_IsScopedToSelectedIed() Assert.Contains("TP-A", text, StringComparison.Ordinal); Assert.DoesNotContain("IED_B", text, StringComparison.Ordinal); Assert.DoesNotContain("TP-B", text, StringComparison.Ordinal); - Assert.DoesNotContain("GGIO1.Ind1.stVal", text, StringComparison.Ordinal); } [Fact] - public void AllPassedBadge_AllowsCompletedRowsThatAreCurrentlyUnchecked() + public void AllPassedBadge_UsesTotalSignalCountRatherThanCheckedRowCount() { var converter = new IoFatAllPassedVisibilityConverter(); var allPassed = converter.Convert( - new object[] { 5, 6 }, + new object[] { 6, 6 }, typeof(Visibility), null!, CultureInfo.InvariantCulture); var stillPending = converter.Convert( - new object[] { 5, 4 }, + new object[] { 6, 5 }, typeof(Visibility), null!, CultureInfo.InvariantCulture); + var xaml = File.ReadAllText(FindRepoFile("IoListTestingWindow.xaml")); Assert.Equal(Visibility.Visible, allPassed); Assert.Equal(Visibility.Collapsed, stillPending); + Assert.Contains("", xaml, StringComparison.Ordinal); } [Fact] From 9ab6581865dd1737b79dc038609f3e0918363ec4 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 10:54:38 +0700 Subject: [PATCH 08/56] Make IO FAT PDF clean, lean, and timestamp focused --- Services/IoTesting/IoFatReportLayoutEngine.cs | 128 ++++++------------ 1 file changed, 45 insertions(+), 83 deletions(-) diff --git a/Services/IoTesting/IoFatReportLayoutEngine.cs b/Services/IoTesting/IoFatReportLayoutEngine.cs index 3a459a4c6..725e7e735 100644 --- a/Services/IoTesting/IoFatReportLayoutEngine.cs +++ b/Services/IoTesting/IoFatReportLayoutEngine.cs @@ -52,7 +52,6 @@ internal static class IoFatReportLayoutEngine private static readonly IoFatReportColor BrandNavy = Color("0F172A"); private static readonly IoFatReportColor BrandBlue = Color("2563EB"); private static readonly IoFatReportColor SoftBlue = Color("EFF6FF"); - private static readonly IoFatReportColor SoftSlate = Color("F8FAFC"); private static readonly IoFatReportColor Border = Color("DDE7F3"); private static readonly IoFatReportColor SoftLine = Color("EEF2F7"); private static readonly IoFatReportColor Muted = Color("64748B"); @@ -90,7 +89,6 @@ public Builder(IoTestProject project, DateTimeOffset created, bool draft) public IoFatReportLayoutPlan Render() { NewPage(); - DrawCustomerSummary(); foreach (var ied in _project.Ieds) DrawIedSection(ied); if (_project.Ieds.Count == 0) @@ -130,8 +128,8 @@ private void DrawPageChrome(PageBuilder page, int pageNumber, int totalPages) page.Line(Margin, HeaderBottom, PageWidth - Margin, HeaderBottom, Border, 0.8d); page.Text(Margin, 562d, 390d, "ARSAS | IEC 61850 FAT", IoFatReportFontKind.Bold, 7.4d, Muted); - page.Text(Margin, 540d, 510d, "IEC 61850 FAT Test Report", IoFatReportFontKind.Bold, 20.6d, BrandNavy); - page.Text(Margin, 520d, 590d, "Each signal was checked OFF > ON > OFF. PASS means both changes were recorded correctly.", IoFatReportFontKind.Regular, 8.1d, Muted); + page.Text(Margin, 540d, 510d, "IEC 61850 FAT Evidence Report", IoFatReportFontKind.Bold, 20.6d, BrandNavy); + page.Text(Margin, 520d, 590d, "Signal state verification with relay timestamps.", IoFatReportFontKind.Regular, 8.1d, Muted); const double cardWidth = 150d; const double cardHeight = 58d; @@ -143,53 +141,14 @@ private void DrawPageChrome(PageBuilder page, int pageNumber, int totalPages) page.Text(cardX + 11d, cardTop - 50d, cardWidth - 22d, Truncate(scope, 30), IoFatReportFontKind.Regular, 6.1d, Muted); page.Line(Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); - page.Text(Margin, 24d, 620d, $"Generated {_created:yyyy-MM-dd HH:mm:ss zzz} | Project {Clean(_project.ProjectId)} | Detailed evidence is stored in the ARSAS project.", IoFatReportFontKind.Regular, 6.3d, Muted); + page.Text(Margin, 24d, 620d, $"Generated {_created:yyyy-MM-dd HH:mm:ss zzz} | Project {Clean(_project.ProjectId)}", IoFatReportFontKind.Regular, 6.3d, Muted); page.Text(PageWidth - Margin - 72d, 24d, 72d, $"Page {pageNumber} / {totalPages}", IoFatReportFontKind.Regular, 6.3d, Muted); } - private void DrawCustomerSummary() - { - var counts = Counts(_project); - var signals = _project.Ieds.Sum(ied => ReportPoints(ied).Count); - var attentionCount = counts.Review + counts.Failed; - const double height = 112d; - Ensure(height + 12d); - - _page.RoundRect(Margin, _cursorY, ContentWidth, height, 6d, SoftSlate, Border, 0.8d); - _page.Text(Margin + 14d, _cursorY - 19d, ContentWidth - 28d, "Test Summary", IoFatReportFontKind.Bold, 11.5d, BrandNavy); - _page.Text(Margin + 14d, _cursorY - 37d, ContentWidth - 28d, $"{Clean(_project.ProjectName)} | {Clean(_project.ProjectId)}", IoFatReportFontKind.Bold, 8.5d, Ink); - - var sourceText = string.IsNullOrWhiteSpace(_project.SourceWorkbookName) - ? $"{_project.Ieds.Count} device(s) included in this report." - : $"Source: {Clean(_project.SourceWorkbookName)} | {_project.Ieds.Count} device(s) included."; - _page.Text(Margin + 14d, _cursorY - 53d, ContentWidth - 28d, sourceText, IoFatReportFontKind.Regular, 7d, Muted); - _page.Text(Margin + 14d, _cursorY - 68d, ContentWidth - 28d, "How to read: PASS confirms the signal changed OFF to ON and returned to OFF in the correct order.", IoFatReportFontKind.Regular, 7d, Muted); - - const double metricTop = 81d; - const double gap = 8d; - var metricWidth = (ContentWidth - 28d - (gap * 3d)) / 4d; - var x = Margin + 14d; - DrawMetric(x, _cursorY - metricTop, metricWidth, "SIGNALS TESTED", signals.ToString(CultureInfo.InvariantCulture), BrandBlue, SoftBlue); - x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "PASSED", counts.Passed.ToString(CultureInfo.InvariantCulture), Pass, SoftPass); - x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "NEEDS REVIEW", attentionCount.ToString(CultureInfo.InvariantCulture), attentionCount > 0 ? Attention : Muted, attentionCount > 0 ? SoftAttention : White); - x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "NOT COMPLETED", counts.Pending.ToString(CultureInfo.InvariantCulture), counts.Pending > 0 ? Attention : Muted, counts.Pending > 0 ? SoftAttention : White); - _cursorY -= height + 12d; - } - - private void DrawMetric(double x, double top, double width, string label, string value, IoFatReportColor color, IoFatReportColor background) - { - _page.RoundRect(x, top, width, 30d, 4d, background, Border, 0.55d); - _page.Text(x + 8d, top - 11d, width - 16d, label, IoFatReportFontKind.Bold, 5.7d, Muted); - _page.Text(x + 8d, top - 24d, width - 16d, value, IoFatReportFontKind.Bold, 10d, color); - } - private void DrawIedSection(IoTestIedPlan ied) { var points = ReportPoints(ied); - Ensure(88d); + Ensure(80d); DrawIedHeader(ied, points, continued: false); DrawTableHeader(); @@ -211,16 +170,12 @@ private void DrawIedSection(IoTestIedPlan ied) if (points.Count == 0) { Ensure(34d); - _page.RoundRect(Margin, _cursorY, ContentWidth, 28d, 4d, SoftSlate, Border, 0.6d); - _page.Text(Margin + 10d, _cursorY - 18d, ContentWidth - 20d, "No signal is currently selected or completed for this device.", IoFatReportFontKind.Regular, 7.2d, Muted); + _page.RoundRect(Margin, _cursorY, ContentWidth, 28d, 4d, SoftAttention, Border, 0.6d); + _page.Text(Margin + 10d, _cursorY - 18d, ContentWidth - 20d, "No signal is currently selected or completed for this device.", IoFatReportFontKind.Regular, 7.2d, Attention); _cursorY -= 38d; } - else - { - DrawOutcomeNote(points); - } - _cursorY -= 11d; + _cursorY -= 12d; } private void DrawIedHeader(IoTestIedPlan ied, IReadOnlyList points, bool continued) @@ -253,13 +208,13 @@ private void DrawTableHeader() { Ensure(24d); var widths = ColumnWidths(); - var headers = new[] { "#", "Signal", "Test sequence", "ON relay time", "OFF relay time", "Result" }; + var headers = new[] { "#", "Signal", "Expected state", "ON / TRUE evidence", "OFF / FALSE evidence", "Result" }; var x = Margin; const double height = 22d; for (var index = 0; index < headers.Length; index++) { _page.Rect(x, _cursorY, widths[index], height, SoftBlue, Border, 0.45d); - _page.Text(x + 5d, _cursorY - 14.5d, widths[index] - 10d, headers[index], IoFatReportFontKind.Bold, 6.25d, BrandBlue); + _page.Text(x + 5d, _cursorY - 14.5d, widths[index] - 10d, headers[index], IoFatReportFontKind.Bold, 6.15d, BrandBlue); x += widths[index]; } _cursorY -= height; @@ -292,10 +247,10 @@ private static ReportCell[] BuildCells(IoTestPointPlan point, int rowNumber) { new ReportCell(rowNumber.ToString(CultureInfo.InvariantCulture), IoFatReportFontKind.Mono, 6d, Ink, 1), new ReportCell(point.SignalName, IoFatReportFontKind.Bold, 6.7d, Ink, 3), - new ReportCell("OFF > ON > OFF\nRecorded in order", IoFatReportFontKind.Regular, 6.3d, Ink, 2), - new ReportCell(RelayTime(point.Runtime.OnEvidence), IoFatReportFontKind.Mono, 6.15d, point.Runtime.OnEvidence == null ? Muted : Pass, 2), - new ReportCell(RelayTime(point.Runtime.OffEvidence), IoFatReportFontKind.Mono, 6.15d, point.Runtime.OffEvidence == null ? Muted : Pass, 2), - new ReportCell(ResultText(point.Runtime.State), IoFatReportFontKind.Bold, 6.45d, stateColor, 2) + new ReportCell(ExpectedStateText(point), IoFatReportFontKind.Regular, 6.35d, Ink, 2), + new ReportCell(RelayTime(point.Runtime.OnEvidence), IoFatReportFontKind.Mono, 6.2d, point.Runtime.OnEvidence == null ? Muted : Pass, 2), + new ReportCell(RelayTime(point.Runtime.OffEvidence), IoFatReportFontKind.Mono, 6.2d, point.Runtime.OffEvidence == null ? Muted : Pass, 2), + new ReportCell(ResultText(point.Runtime.State), IoFatReportFontKind.Bold, 6.6d, stateColor, 1) }; } @@ -305,25 +260,10 @@ private static double EstimateRowHeight(IReadOnlyList cells) var maximum = 1; for (var index = 0; index < cells.Count; index++) maximum = Math.Max(maximum, WrapText(cells[index].Text, widths[index] - 10d, cells[index].FontSize, cells[index].MaxLines).Count); - return Math.Max(30d, 10d + (maximum * 8.2d)); + return Math.Max(29d, 10d + (maximum * 8.2d)); } - private static double[] ColumnWidths() => new[] { 24d, 242d, 112d, 130d, 130d, 144d }; - - private void DrawOutcomeNote(IReadOnlyList points) - { - Ensure(37d); - var passed = points.Count(point => point.Runtime.State == IoTestPointState.Passed); - var allPassed = passed == points.Count; - var fill = allPassed ? SoftPass : SoftAttention; - var color = allPassed ? Pass : Attention; - var text = allPassed - ? "All listed signals completed the OFF > ON > OFF test successfully." - : "Some signals are incomplete or need review. Technical details remain available in the ARSAS project and Excel export."; - _page.RoundRect(Margin, _cursorY - 7d, ContentWidth, 27d, 5d, fill, color, 0.6d); - _page.Text(Margin + 11d, _cursorY - 24d, ContentWidth - 22d, text, IoFatReportFontKind.Bold, 7.1d, color); - _cursorY -= 36d; - } + private static double[] ColumnWidths() => new[] { 24d, 230d, 150d, 145d, 145d, 88d }; private void DrawEmptyProjectNotice() { @@ -405,18 +345,40 @@ private static string ResolveOverallTone(ProjectCounts counts) private static string ResultText(IoTestPointState state) => state switch { - IoTestPointState.Passed => "PASS\nVerified", - IoTestPointState.Review => "REVIEW\nPlease check", - IoTestPointState.Failed => "FAILED\nDid not pass", - _ => "PENDING\nNot completed" + IoTestPointState.Passed => "PASS", + IoTestPointState.Review => "REVIEW", + IoTestPointState.Failed => "FAILED", + _ => "PENDING" }; + private static string ExpectedStateText(IoTestPointPlan point) + { + var trueLabel = NormalizeExpectedLabel(point.ExpectedOnText, "ON", "True"); + var falseLabel = NormalizeExpectedLabel(point.ExpectedOffText, "OFF", "False"); + return $"{trueLabel} (True)\n{falseLabel} (False)"; + } + + private static string NormalizeExpectedLabel(string? value, string prefix, string booleanText) + { + var clean = Clean(value); + if (clean.Equals("1", StringComparison.OrdinalIgnoreCase) || + clean.Equals("true", StringComparison.OrdinalIgnoreCase)) + return "ON"; + if (clean.Equals("0", StringComparison.OrdinalIgnoreCase) || + clean.Equals("false", StringComparison.OrdinalIgnoreCase)) + return "OFF"; + + var repeatedPrefix = prefix + " "; + if (clean.StartsWith(repeatedPrefix, StringComparison.OrdinalIgnoreCase)) + clean = clean[repeatedPrefix.Length..].Trim(); + + return string.IsNullOrWhiteSpace(clean) || clean == "-" ? booleanText : clean; + } + private static string RelayTime(IoTestTransitionEvidence? evidence) { - if (evidence == null) - return "Not captured"; - if (evidence.IedTimestamp == null) - return "Captured\nRelay time unavailable"; + if (evidence?.IedTimestamp == null) + return "-"; return evidence.IedTimestamp.Value.ToString("yyyy-MM-dd\nHH:mm:ss.fff", CultureInfo.InvariantCulture); } From 3e8e247c4de81d2095df96f2969b5276baac7c25 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 10:56:27 +0700 Subject: [PATCH 09/56] Lock clean lean IO FAT report contract --- .../IoFatReportPreviewServiceTests.cs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs index 9ed2e204c..821bcd949 100644 --- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs +++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs @@ -66,17 +66,26 @@ public void AllPassedBadge_UsesTotalSignalCountRatherThanCheckedRowCount() } [Fact] - public void NativeReportLayout_UsesCustomerFriendlyColumnsAndPlainLanguage() + public void NativeReportLayout_IsCleanLeanAndTimestampFocused() { var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportLayoutEngine.cs")); - Assert.Contains("PASS means both changes were recorded correctly", source, StringComparison.Ordinal); - Assert.Contains("How to read: PASS confirms", source, StringComparison.Ordinal); - Assert.Contains("\"Signal\", \"Test sequence\", \"ON relay time\", \"OFF relay time\", \"Result\"", source, StringComparison.Ordinal); + Assert.Contains("Signal state verification with relay timestamps", source, StringComparison.Ordinal); + Assert.Contains("\"Signal\", \"Expected state\", \"ON / TRUE evidence\", \"OFF / FALSE evidence\", \"Result\"", source, StringComparison.Ordinal); + Assert.Contains("ExpectedStateText(point)", source, StringComparison.Ordinal); + Assert.Contains("{trueLabel} (True)", source, StringComparison.Ordinal); + Assert.Contains("{falseLabel} (False)", source, StringComparison.Ordinal); + Assert.Contains("yyyy-MM-dd\\nHH:mm:ss.fff", source, StringComparison.Ordinal); Assert.Contains("point.TestEnabled || point.Runtime.IsComplete", source, StringComparison.Ordinal); Assert.Contains("ALL SIGNALS PASSED", source, StringComparison.Ordinal); + + Assert.DoesNotContain("DrawCustomerSummary", source, StringComparison.Ordinal); + Assert.DoesNotContain("Project Evidence Summary", source, StringComparison.Ordinal); + Assert.DoesNotContain("Test Summary", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Reason\"", source, StringComparison.Ordinal); Assert.DoesNotContain("\"IEC 61850 reference\"", source, StringComparison.Ordinal); - Assert.DoesNotContain("Workbook SHA-256:", source, StringComparison.Ordinal); + Assert.DoesNotContain("evidence.Quality", source, StringComparison.Ordinal); + Assert.DoesNotContain("AcquisitionSource", source, StringComparison.Ordinal); Assert.DoesNotContain("EvidenceText(", source, StringComparison.Ordinal); } @@ -179,8 +188,8 @@ public void IoFatPreparation_UsesCachedReconnect_AndRetriesReportPlanBeforePolli SignalName = signalName, ObjectReference = $"{name}LD/GGIO1.Ind1.stVal", FunctionalConstraint = "ST", - ExpectedOnText = "true", - ExpectedOffText = "false" + ExpectedOnText = "ON Operated", + ExpectedOffText = "OFF Normal" } } }; From 3de96481bdfd0ddfd9ee8dfca00d117a34ccdb12 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 11:14:52 +0700 Subject: [PATCH 10/56] Fix IO FAT preview text clipping and ellipsis --- Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs index cf91ab83c..6c3be26c9 100644 --- a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs +++ b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs @@ -123,10 +123,14 @@ private static void AddText(FixedPage page, double pageHeight, IoFatReportTextCo FontSize = fontSize, FontWeight = command.Font == IoFatReportFontKind.Bold ? FontWeights.Bold : FontWeights.Normal, Foreground = ToBrush(command.Color), - TextTrimming = TextTrimming.CharacterEllipsis, + // Report text is already wrapped and bounded by IoFatReportLayoutEngine. + // CharacterEllipsis made complete evidence look truncated (for example + // timestamps and PASS became "..."), which is unacceptable in evidence. + TextTrimming = TextTrimming.None, TextWrapping = TextWrapping.NoWrap, LineStackingStrategy = LineStackingStrategy.BlockLineHeight, LineHeight = Math.Max(fontSize + 1.5d, fontSize * 1.18d), + ClipToBounds = false, SnapsToDevicePixels = true }; block.SetValue(TextOptions.TextFormattingModeProperty, TextFormattingMode.Ideal); @@ -137,7 +141,7 @@ private static void AddText(FixedPage page, double pageHeight, IoFatReportTextCo command.X * DipPerPdfPoint, top, Math.Max(4d, command.Width * DipPerPdfPoint), - Math.Max(fontSize + 3d, fontSize * 1.25d)); + Math.Max(fontSize + 6d, fontSize * 1.65d)); } private static void Add(FixedPage page, UIElement element, double x, double y, double width, double height) From 55ab71a1947e4cbebd61d57e3c8b9ccf11713b14 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 11:15:43 +0700 Subject: [PATCH 11/56] Add regression guard against truncated FAT evidence text --- tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs index 821bcd949..62fb0b86c 100644 --- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs +++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs @@ -106,10 +106,13 @@ public void PreviewWorkspace_UsesAriecNativeDocumentViewerPattern() Assert.Contains("PageCount", previewSource, StringComparison.Ordinal); Assert.Contains("Native preview", previewSource, StringComparison.Ordinal); Assert.Contains("IoFatReportLayoutEngine.Build", builderSource, StringComparison.Ordinal); + Assert.Contains("TextTrimming = TextTrimming.None", builderSource, StringComparison.Ordinal); + Assert.Contains("ClipToBounds = false", builderSource, StringComparison.Ordinal); Assert.Contains("BuildLayout", pdfSource, StringComparison.Ordinal); Assert.Contains("IoFatReportLayoutPlan", layoutSource, StringComparison.Ordinal); Assert.Contains("DRAFT / LIVE", layoutSource, StringComparison.Ordinal); + Assert.DoesNotContain("TextTrimming.CharacterEllipsis", builderSource, StringComparison.Ordinal); Assert.DoesNotContain("WebBrowser", previewSource, StringComparison.Ordinal); Assert.DoesNotContain("NavigateToString", previewSource, StringComparison.Ordinal); Assert.DoesNotContain("BuildHtml", previewSource, StringComparison.Ordinal); From f98f6d86b9fc7bf2d75229b4d8276fc298d969d7 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 11:17:24 +0700 Subject: [PATCH 12/56] Fit complete FAT evidence text without truncation --- .../IoFatReportPreviewDocumentBuilder.cs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs index 6c3be26c9..482889fc5 100644 --- a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs +++ b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs @@ -123,21 +123,33 @@ private static void AddText(FixedPage page, double pageHeight, IoFatReportTextCo FontSize = fontSize, FontWeight = command.Font == IoFatReportFontKind.Bold ? FontWeights.Bold : FontWeights.Normal, Foreground = ToBrush(command.Color), - // Report text is already wrapped and bounded by IoFatReportLayoutEngine. - // CharacterEllipsis made complete evidence look truncated (for example - // timestamps and PASS became "..."), which is unacceptable in evidence. + // The layout engine has already split every cell into explicit lines. + // Evidence must never be replaced with dots in the preview. TextTrimming = TextTrimming.None, TextWrapping = TextWrapping.NoWrap, - LineStackingStrategy = LineStackingStrategy.BlockLineHeight, - LineHeight = Math.Max(fontSize + 1.5d, fontSize * 1.18d), ClipToBounds = false, SnapsToDevicePixels = true }; block.SetValue(TextOptions.TextFormattingModeProperty, TextFormattingMode.Ideal); block.SetValue(TextOptions.TextRenderingModeProperty, TextRenderingMode.ClearType); + + // Downscale only when the actual Windows font metrics are wider than the + // shared PDF-point estimate. This keeps PASS and timestamps complete while + // preserving the intended size whenever they already fit. + var textPresenter = new Viewbox + { + Child = block, + Stretch = Stretch.Uniform, + StretchDirection = StretchDirection.DownOnly, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + ClipToBounds = false, + SnapsToDevicePixels = true + }; + Add( page, - block, + textPresenter, command.X * DipPerPdfPoint, top, Math.Max(4d, command.Width * DipPerPdfPoint), From 3339867e4cdcfd9d08184824fcfab23447517ea4 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 12:38:10 +0700 Subject: [PATCH 13/56] Fix false ellipsis in IO FAT evidence rows --- Services/IoTesting/IoFatReportLayoutEngine.cs | 146 ++++++++++++------ 1 file changed, 95 insertions(+), 51 deletions(-) diff --git a/Services/IoTesting/IoFatReportLayoutEngine.cs b/Services/IoTesting/IoFatReportLayoutEngine.cs index 725e7e735..b3f9447f8 100644 --- a/Services/IoTesting/IoFatReportLayoutEngine.cs +++ b/Services/IoTesting/IoFatReportLayoutEngine.cs @@ -21,6 +21,7 @@ public static IoFatReportColor FromHex(string hex) var value = hex.StartsWith("#", StringComparison.Ordinal) ? hex[1..] : hex; if (value.Length != 6) throw new ArgumentException("Report color must be a six-digit RGB hex value.", nameof(hex)); + return new IoFatReportColor( Convert.ToByte(value[..2], 16), Convert.ToByte(value.Substring(2, 2), 16), @@ -43,6 +44,7 @@ internal static class IoFatReportLayoutEngine { public const double PageWidth = 842d; public const double PageHeight = 595d; + private const double Margin = 30d; private const double HeaderBottom = 502d; private const double ContentTop = 486d; @@ -89,8 +91,10 @@ public Builder(IoTestProject project, DateTimeOffset created, bool draft) public IoFatReportLayoutPlan Render() { NewPage(); + foreach (var ied in _project.Ieds) DrawIedSection(ied); + if (_project.Ieds.Count == 0) DrawEmptyProjectNotice(); @@ -102,7 +106,9 @@ public IoFatReportLayoutPlan Render() _project.ProjectId, _created, _draft, - _pages.Select((page, index) => new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, page.Commands.ToArray())).ToArray()); + _pages.Select((page, index) => + new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, page.Commands.ToArray())) + .ToArray()); } private void NewPage() @@ -135,10 +141,11 @@ private void DrawPageChrome(PageBuilder page, int pageNumber, int totalPages) const double cardHeight = 58d; var cardX = PageWidth - Margin - cardWidth; const double cardTop = 566d; + page.RoundRect(cardX, cardTop, cardWidth, cardHeight, 6d, toneBackground, toneColor, 0.9d); page.Text(cardX + 11d, cardTop - 15d, cardWidth - 22d, _draft ? "PREVIEW" : "OVERALL RESULT", IoFatReportFontKind.Bold, 6.4d, Muted); page.Text(cardX + 11d, cardTop - 36d, cardWidth - 22d, tone, IoFatReportFontKind.Bold, 16.4d, toneColor); - page.Text(cardX + 11d, cardTop - 50d, cardWidth - 22d, Truncate(scope, 30), IoFatReportFontKind.Regular, 6.1d, Muted); + page.Text(cardX + 11d, cardTop - 50d, cardWidth - 22d, FitHeaderText(scope, 30), IoFatReportFontKind.Regular, 6.1d, Muted); page.Line(Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); page.Text(Margin, 24d, 620d, $"Generated {_created:yyyy-MM-dd HH:mm:ss zzz} | Project {Clean(_project.ProjectId)}", IoFatReportFontKind.Regular, 6.3d, Muted); @@ -158,12 +165,14 @@ private void DrawIedSection(IoTestIedPlan ied) rowNumber++; var cells = BuildCells(point, rowNumber); var rowHeight = EstimateRowHeight(cells); + if (_cursorY - rowHeight < ContentBottom) { NewPage(); DrawIedHeader(ied, points, continued: true); DrawTableHeader(); } + DrawRow(cells, rowHeight); } @@ -211,12 +220,14 @@ private void DrawTableHeader() var headers = new[] { "#", "Signal", "Expected state", "ON / TRUE evidence", "OFF / FALSE evidence", "Result" }; var x = Margin; const double height = 22d; + for (var index = 0; index < headers.Length; index++) { _page.Rect(x, _cursorY, widths[index], height, SoftBlue, Border, 0.45d); _page.Text(x + 5d, _cursorY - 14.5d, widths[index] - 10d, headers[index], IoFatReportFontKind.Bold, 6.15d, BrandBlue); x += widths[index]; } + _cursorY -= height; } @@ -224,19 +235,23 @@ private void DrawRow(IReadOnlyList cells, double 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.35d); - var lines = WrapText(cell.Text, widths[index] - 10d, cell.FontSize, cell.MaxLines); + + var lines = WrapText(cell.Text, widths[index] - 10d, cell.FontSize); var y = _cursorY - 10d; foreach (var line in lines) { _page.Text(x + 5d, y, widths[index] - 10d, line, cell.Font, cell.FontSize, cell.Color); y -= cell.FontSize + 1.7d; } + x += widths[index]; } + _cursorY -= rowHeight; } @@ -245,12 +260,12 @@ private static ReportCell[] BuildCells(IoTestPointPlan point, int rowNumber) var stateColor = ResolvePointColor(point.Runtime.State); return new[] { - new ReportCell(rowNumber.ToString(CultureInfo.InvariantCulture), IoFatReportFontKind.Mono, 6d, Ink, 1), - new ReportCell(point.SignalName, IoFatReportFontKind.Bold, 6.7d, Ink, 3), - new ReportCell(ExpectedStateText(point), IoFatReportFontKind.Regular, 6.35d, Ink, 2), - new ReportCell(RelayTime(point.Runtime.OnEvidence), IoFatReportFontKind.Mono, 6.2d, point.Runtime.OnEvidence == null ? Muted : Pass, 2), - new ReportCell(RelayTime(point.Runtime.OffEvidence), IoFatReportFontKind.Mono, 6.2d, point.Runtime.OffEvidence == null ? Muted : Pass, 2), - new ReportCell(ResultText(point.Runtime.State), IoFatReportFontKind.Bold, 6.6d, stateColor, 1) + new ReportCell(rowNumber.ToString(CultureInfo.InvariantCulture), IoFatReportFontKind.Mono, 6d, Ink), + new ReportCell(point.SignalName, IoFatReportFontKind.Bold, 6.7d, Ink), + new ReportCell(ExpectedStateText(point), IoFatReportFontKind.Regular, 6.35d, Ink), + new ReportCell(RelayTime(point.Runtime.OnEvidence), IoFatReportFontKind.Mono, 6.2d, point.Runtime.OnEvidence == null ? Muted : Pass), + new ReportCell(RelayTime(point.Runtime.OffEvidence), IoFatReportFontKind.Mono, 6.2d, point.Runtime.OffEvidence == null ? Muted : Pass), + new ReportCell(ResultText(point.Runtime.State), IoFatReportFontKind.Bold, 6.6d, stateColor) }; } @@ -258,8 +273,10 @@ private static double EstimateRowHeight(IReadOnlyList cells) { var widths = ColumnWidths(); var maximum = 1; + for (var index = 0; index < cells.Count; index++) - maximum = Math.Max(maximum, WrapText(cells[index].Text, widths[index] - 10d, cells[index].FontSize, cells[index].MaxLines).Count); + maximum = Math.Max(maximum, WrapText(cells[index].Text, widths[index] - 10d, cells[index].FontSize).Count); + return Math.Max(29d, 10d + (maximum * 8.2d)); } @@ -274,7 +291,7 @@ private void DrawEmptyProjectNotice() } } - private sealed record ReportCell(string Text, IoFatReportFontKind Font, double FontSize, IoFatReportColor Color, int MaxLines); + private sealed record ReportCell(string Text, IoFatReportFontKind Font, double FontSize, IoFatReportColor Color); private sealed class PageBuilder { @@ -361,9 +378,20 @@ private static string ExpectedStateText(IoTestPointPlan point) private static string NormalizeExpectedLabel(string? value, string prefix, string booleanText) { var clean = Clean(value); + + foreach (var suffix in new[] { " (1)", " (0)", " (True)", " (False)" }) + { + if (clean.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + clean = clean[..^suffix.Length].Trim(); + break; + } + } + if (clean.Equals("1", StringComparison.OrdinalIgnoreCase) || clean.Equals("true", StringComparison.OrdinalIgnoreCase)) return "ON"; + if (clean.Equals("0", StringComparison.OrdinalIgnoreCase) || clean.Equals("false", StringComparison.OrdinalIgnoreCase)) return "OFF"; @@ -379,6 +407,7 @@ private static string RelayTime(IoTestTransitionEvidence? evidence) { if (evidence?.IedTimestamp == null) return "-"; + return evidence.IedTimestamp.Value.ToString("yyyy-MM-dd\nHH:mm:ss.fff", CultureInfo.InvariantCulture); } @@ -393,75 +422,85 @@ private static string BuildDeviceMeta(IoTestIedPlan ied) return values.Count == 0 ? "Device details not supplied" : string.Join(" | ", values); } - private static IReadOnlyList WrapText(string? value, double width, double fontSize, int maxLines) + /// + /// Wraps text without inserting ellipsis. Evidence rows must never imply that + /// a timestamp, expected state, or result is incomplete when it is actually present. + /// + private static IReadOnlyList WrapText(string? value, double width, double fontSize) { - var input = (value ?? string.Empty).Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); - if (string.IsNullOrWhiteSpace(input)) return new[] { "-" }; + 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.4d, fontSize * 0.49d))); var lines = new List(); - var truncated = false; + foreach (var paragraphValue in input.Split('\n')) { var paragraph = SanitizeReportText(paragraphValue); - if (paragraph.Length == 0) paragraph = "-"; + if (paragraph.Length == 0) + { + lines.Add("-"); + continue; + } + 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); + + if (word.Length == 0) + continue; + + 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 (current.Length > 0) + lines.Add(current.ToString()); } - 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; + + return lines.Count == 0 ? new[] { "-" } : lines; } internal static string SanitizeReportText(string? value) { - var normalized = (value ?? string.Empty).Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal).Trim(); - if (string.IsNullOrWhiteSpace(normalized)) return "-"; + var normalized = (value ?? string.Empty) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal) + .Trim(); + + if (string.IsNullOrWhiteSpace(normalized)) + return "-"; + var builder = new StringBuilder(normalized.Length); foreach (var character in normalized) { @@ -476,19 +515,24 @@ internal static string SanitizeReportText(string? value) _ => ' ' }); } + return builder.ToString().Trim(); } private static string Clean(string? value) { - var normalized = (value ?? string.Empty).Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal).Trim(); + var normalized = (value ?? string.Empty) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal) + .Trim(); + return string.IsNullOrWhiteSpace(normalized) ? "-" : normalized; } - private static string Truncate(string? value, int maximum) + private static string FitHeaderText(string? value, int maximum) { var clean = Clean(value); - return clean.Length <= maximum || maximum <= 3 ? clean : clean[..(maximum - 3)] + "..."; + return clean.Length <= maximum || maximum <= 1 ? clean : clean[..maximum]; } private static IoFatReportColor Color(string hex) => IoFatReportColor.FromHex(hex); From b3252e2fc4d84a1ecd1f07413b9651efbdf360f6 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 12:39:05 +0700 Subject: [PATCH 14/56] Add regression guard against ellipsized FAT evidence --- tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs index 62fb0b86c..5b0ef79e8 100644 --- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs +++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs @@ -78,6 +78,7 @@ public void NativeReportLayout_IsCleanLeanAndTimestampFocused() Assert.Contains("yyyy-MM-dd\\nHH:mm:ss.fff", source, StringComparison.Ordinal); Assert.Contains("point.TestEnabled || point.Runtime.IsComplete", source, StringComparison.Ordinal); Assert.Contains("ALL SIGNALS PASSED", source, StringComparison.Ordinal); + Assert.Contains("Wraps text without inserting ellipsis", source, StringComparison.Ordinal); Assert.DoesNotContain("DrawCustomerSummary", source, StringComparison.Ordinal); Assert.DoesNotContain("Project Evidence Summary", source, StringComparison.Ordinal); @@ -87,6 +88,8 @@ public void NativeReportLayout_IsCleanLeanAndTimestampFocused() Assert.DoesNotContain("evidence.Quality", source, StringComparison.Ordinal); Assert.DoesNotContain("AcquisitionSource", source, StringComparison.Ordinal); Assert.DoesNotContain("EvidenceText(", source, StringComparison.Ordinal); + Assert.DoesNotContain("var truncated", source, StringComparison.Ordinal); + Assert.DoesNotContain(" + \"...\"", source, StringComparison.Ordinal); } [Fact] @@ -207,6 +210,7 @@ private static string FindRepoFile(string relativePath) return candidate; directory = directory.Parent; } + throw new FileNotFoundException($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); } } From df857ffe89a1e5278b78681150fed8ebaf4c788b Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 14:58:26 +0700 Subject: [PATCH 15/56] Preserve Rev3 document control and event-log traceability --- Models/IoTesting/IoTestModels.cs | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index 92afeea3b..cfff64c4d 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -221,6 +221,17 @@ public sealed class IoTestPointPlan : ObservableObject public string LogicalNode { get; init; } = string.Empty; public string DataObject { get; init; } = string.Empty; public string DataAttribute { get; init; } = string.Empty; + public string Cdc { get; init; } = string.Empty; + public string SourceIecReference { get; init; } = string.Empty; + public string ReportDisplayReference { get; init; } = string.Empty; + public string EventLogSearchReference { get; init; } = string.Empty; + public string EvidenceExpected { get; init; } = string.Empty; + public string MappingQuality { get; init; } = string.Empty; + public string ReviewStatus { get; init; } = string.Empty; + public string ReviewReason { get; init; } = string.Empty; + public string EventLogMatch { get; init; } = string.Empty; + public string EvidenceReference { get; init; } = string.Empty; + public string ReviewerComment { get; init; } = string.Empty; public string SourceSheet { get; init; } = string.Empty; public int SourceRow { get; init; } public bool TestEnabled { get => _testEnabled; set => Set(ref _testEnabled, value); } @@ -229,6 +240,21 @@ public sealed class IoTestPointPlan : ObservableObject public string BindingEvidence { get; init; } = string.Empty; public IoTestPointRuntime Runtime { get; } = new(); + [JsonIgnore] + public string ReportIecReference + { + get + { + if (!string.IsNullOrWhiteSpace(EventLogSearchReference)) + return EventLogSearchReference.Trim(); + if (!string.IsNullOrWhiteSpace(SourceIecReference)) + return SourceIecReference.Trim(); + if (!string.IsNullOrWhiteSpace(ReportDisplayReference)) + return ReportDisplayReference.Trim(); + return ObjectReference; + } + } + public IoTestLiveBindingState LiveBindingState { get => _liveBindingState; @@ -401,6 +427,19 @@ private void RaiseProgressProperties() } } +public sealed class IoFatDocumentControl +{ + public string ClientProject { get; init; } = string.Empty; + public string SupplierName { get; init; } = string.Empty; + public string PurchaseOrderTitle { get; init; } = string.Empty; + public string PurchaserDocumentNumber { get; init; } = string.Empty; + public string CompanyProjectDocumentNumber { get; init; } = string.Empty; + public string DocumentTitle { get; init; } = string.Empty; + public string Revision { get; init; } = string.Empty; + public string IssueStatus { get; init; } = string.Empty; + public string SourceDocumentName { get; init; } = string.Empty; +} + public sealed class IoTestProject { public required string ProjectId { get; init; } @@ -409,6 +448,7 @@ public sealed class IoTestProject public string SourceWorkbookName { get; init; } = string.Empty; public string SourceWorkbookSha256 { get; init; } = string.Empty; public DateTimeOffset ImportedAt { get; init; } = DateTimeOffset.UtcNow; + public IoFatDocumentControl DocumentControl { get; init; } = new(); public List Ieds { get; init; } = new(); public int SignalCount => Ieds.Sum(ied => ied.TestPoints.Count); From a60b19c395b4ed36d8a97c82f85098a5d29bde4b Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 15:02:36 +0700 Subject: [PATCH 16/56] Import event-log-ready FAT workbook schema --- .../IoTesting/IoListExcelImportService.cs | 607 +++++++++--------- 1 file changed, 298 insertions(+), 309 deletions(-) diff --git a/Services/IoTesting/IoListExcelImportService.cs b/Services/IoTesting/IoListExcelImportService.cs index 36a39cdf0..c16040850 100644 --- a/Services/IoTesting/IoListExcelImportService.cs +++ b/Services/IoTesting/IoListExcelImportService.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.IO.Compression; +using System.Net; using System.Security.Cryptography; +using System.Text.RegularExpressions; using System.Xml.Linq; using ArIED61850Tester.Models.IoTesting; @@ -13,424 +15,411 @@ public sealed record IoListExcelImportResult( int ParsedRowCount) { public IReadOnlyList AllFindings => ParserFindings.Concat(Validation.Findings).ToList(); - public bool IsValid => ParserFindings.All(finding => finding.Severity != IoTestImportFindingSeverity.Error) && Validation.IsValid; + public bool IsValid => ParserFindings.All(x => x.Severity != IoTestImportFindingSeverity.Error) && Validation.IsValid; } public sealed class IoListExcelImportService { - private const string SignalSheetName = "ARSAS_SIGNAL_IMPORT"; + private const string LegacySheet = "ARSAS_SIGNAL_IMPORT"; + private const string Rev3Sheet = "FAT_Points_Import"; + private const string DocumentSheet = "Document_Control"; private const int MaxWorkbookBytes = 50 * 1024 * 1024; - private const int MaxSignalRows = 20_000; - 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 const int MaxRows = 20_000; - private static readonly string[] RequiredHeaders = - { + private static readonly XNamespace S = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static readonly XNamespace P = "http://schemas.openxmlformats.org/package/2006/relationships"; + + private static readonly string[] LegacyRequired = + [ "ProjectId", "SchemaVersion", "TestPointId", "TestEnabled", "ImportReady", "BindingStatus", "SourceSheet", "SourceRow", "IEDName", "IPAddress", "SignalName", "DataType", "FC", "ObjectReference", "ExpectedONRaw", "ExpectedONText", "ExpectedOFFRaw", "ExpectedOFFText" - }; + ]; + + private static readonly string[] Rev3Required = + [ + "Point ID", "Include in FAT", "Source Sheet", "Source Row", + "IED Identifier / Technical Key", "IP Address", "Signal Description", + "Data Type", "FC", "IEC 61850 Reference (Source)", + "State 0/01 Text (Source)", "State 1/10 Text (Source)", "Review Status" + ]; private readonly IoTestImportValidator _validator; public IoListExcelImportService(IoTestImportValidator? validator = null) - { - _validator = validator ?? new IoTestImportValidator(); - } + => _validator = validator ?? new IoTestImportValidator(); public Task ImportAsync(string filePath, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(filePath)) - throw new ArgumentException("An Excel file path is required.", nameof(filePath)); - + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); return Task.Run(() => { cancellationToken.ThrowIfCancellationRequested(); var info = new FileInfo(filePath); - if (!info.Exists) - throw new FileNotFoundException("The IO List workbook was not found.", filePath); - if (info.Length > MaxWorkbookBytes) - throw new InvalidDataException($"The IO List workbook exceeds the {MaxWorkbookBytes / 1024 / 1024} MB safety limit."); - - var bytes = File.ReadAllBytes(filePath); - cancellationToken.ThrowIfCancellationRequested(); - return Import(bytes, Path.GetFileName(filePath), cancellationToken); + if (!info.Exists) throw new FileNotFoundException("The IO List workbook was not found.", filePath); + if (info.Length > MaxWorkbookBytes) throw new InvalidDataException("The IO List workbook exceeds the 50 MB safety limit."); + return Import(File.ReadAllBytes(filePath), Path.GetFileName(filePath), cancellationToken); }, cancellationToken); } public IoListExcelImportResult Import(byte[] workbookBytes, string sourceFileName, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(workbookBytes); - if (workbookBytes.Length == 0) - throw new InvalidDataException("The IO List workbook is empty."); - if (workbookBytes.Length > MaxWorkbookBytes) - throw new InvalidDataException($"The IO List workbook exceeds the {MaxWorkbookBytes / 1024 / 1024} MB safety limit."); + if (workbookBytes.Length == 0) throw new InvalidDataException("The IO List workbook is empty."); + if (workbookBytes.Length > MaxWorkbookBytes) throw new InvalidDataException("The IO List workbook exceeds the 50 MB safety limit."); - var parserFindings = new List(); using var memory = new MemoryStream(workbookBytes, writable: false); - using var archive = new ZipArchive(memory, ZipArchiveMode.Read, leaveOpen: false); - var rows = ReadSheetRows(archive, SignalSheetName, cancellationToken); - if (rows.Count == 0) - throw new InvalidDataException($"Sheet '{SignalSheetName}' contains no rows."); - - var headerRow = rows[0]; - var headers = headerRow.Cells - .Where(item => !string.IsNullOrWhiteSpace(item.Value)) - .ToDictionary(item => item.Key, item => item.Value.Trim(), EqualityComparer.Default); - var duplicateHeaders = headers.Values - .GroupBy(value => value, StringComparer.OrdinalIgnoreCase) - .Where(group => group.Count() > 1) - .Select(group => group.Key) - .ToList(); - foreach (var duplicate in duplicateHeaders) - { - parserFindings.Add(new IoTestImportFinding( - IoTestImportFindingSeverity.Error, - "XLSX_HEADER_DUPLICATE", - $"Column header '{duplicate}' occurs more than once in sheet '{SignalSheetName}'.", - SourceSheet: SignalSheetName, - SourceRow: headerRow.RowNumber)); - } - var headerLookup = headers - .GroupBy(item => item.Value, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.First().Key, StringComparer.OrdinalIgnoreCase); - - foreach (var required in RequiredHeaders) - { - if (!headerLookup.ContainsKey(required)) - { - parserFindings.Add(new IoTestImportFinding( - IoTestImportFindingSeverity.Error, - "XLSX_HEADER_MISSING", - $"Required column '{required}' is missing from sheet '{SignalSheetName}'.", - SourceSheet: SignalSheetName, - SourceRow: headerRow.RowNumber)); - } - } - - if (parserFindings.Any(finding => finding.Severity == IoTestImportFindingSeverity.Error)) - return BuildEmptyResult(sourceFileName, workbookBytes, parserFindings); - - var pointRows = new List(); + using var archive = new ZipArchive(memory, ZipArchiveMode.Read); + var findings = new List(); + var rev3 = HasSheet(archive, Rev3Sheet); + var sheetName = rev3 ? Rev3Sheet : HasSheet(archive, LegacySheet) + ? LegacySheet + : throw new InvalidDataException($"Expected sheet '{Rev3Sheet}' or '{LegacySheet}'."); + var rows = ReadRows(archive, sheetName, true, cancellationToken); + if (rows.Count == 0) throw new InvalidDataException($"Sheet '{sheetName}' contains no rows."); + + var headers = Headers(rows[0], sheetName, findings); + foreach (var required in rev3 ? Rev3Required : LegacyRequired) + if (!headers.ContainsKey(required)) + findings.Add(Finding(IoTestImportFindingSeverity.Error, "XLSX_HEADER_MISSING", + $"Required column '{required}' is missing from sheet '{sheetName}'.", sheet: sheetName, row: rows[0].Number)); + if (findings.Any(x => x.Severity == IoTestImportFindingSeverity.Error)) + return Empty(sourceFileName, workbookBytes, findings); + + var control = rev3 ? ReadDocumentControl(archive, sourceFileName, cancellationToken) : new IoFatDocumentControl(); + var projectId = rev3 + ? First(control.CompanyProjectDocumentNumber, control.PurchaserDocumentNumber, Path.GetFileNameWithoutExtension(sourceFileName)) + : string.Empty; + var projectName = rev3 + ? First(control.ClientProject, control.PurchaseOrderTitle, Path.GetFileNameWithoutExtension(sourceFileName)) + : Path.GetFileNameWithoutExtension(sourceFileName); + + var imported = new List(); + var nonSdi = 0; + var blocked = 0; + var excluded = 0; + var endpoint = 0; foreach (var row in rows.Skip(1)) { cancellationToken.ThrowIfCancellationRequested(); - if (pointRows.Count >= MaxSignalRows) - throw new InvalidDataException($"The IO List exceeds the {MaxSignalRows:N0} signal-row safety limit."); + if (imported.Count >= MaxRows) throw new InvalidDataException($"The IO List exceeds the {MaxRows:N0} row safety limit."); + var source = Values(row, headers); + var values = rev3 ? MapRev3(source, projectId) : source; + if (string.IsNullOrWhiteSpace(Get(values, "TestPointId"))) continue; - var values = BuildRowValues(row, headerLookup); - var testPointId = Get(values, "TestPointId"); - if (string.IsNullOrWhiteSpace(testPointId)) - continue; - - var dataType = Get(values, "DataType"); - if (!string.Equals(dataType, "SDI", StringComparison.OrdinalIgnoreCase)) + if (rev3) { - parserFindings.Add(new IoTestImportFinding( - IoTestImportFindingSeverity.Warning, - "XLSX_NON_SDI_SKIPPED", - $"Test point '{testPointId}' uses DataType '{dataType}' and was skipped by the SDI-only first release.", - testPointId, - SignalSheetName, - row.RowNumber)); - continue; + if (Get(source, "Include in FAT").Equals("NO", StringComparison.OrdinalIgnoreCase)) { excluded++; continue; } + if (Get(source, "Review Status").StartsWith("BLOCKED", StringComparison.OrdinalIgnoreCase)) { blocked++; continue; } + if (string.IsNullOrWhiteSpace(Get(values, "IEDName")) || !IPAddress.TryParse(Get(values, "IPAddress"), out _)) + { endpoint++; continue; } } - pointRows.Add(new ImportedPointRow(row.RowNumber, values)); + if (!Get(values, "DataType").Equals("SDI", StringComparison.OrdinalIgnoreCase)) { nonSdi++; continue; } + imported.Add(new ImportedRow(row.Number, values)); } - if (pointRows.Count == 0) + AddSkip(findings, sheetName, "XLSX_NON_SDI_SKIPPED", nonSdi, "non-SDI points were skipped because automatic transition testing is SDI-only"); + AddSkip(findings, sheetName, "XLSX_BLOCKED_ROWS_SKIPPED", blocked, "blocked points were skipped because their IEC 61850 identity is incomplete"); + AddSkip(findings, sheetName, "XLSX_EXCLUDED_ROWS_SKIPPED", excluded, "points marked Include in FAT = NO were skipped"); + AddSkip(findings, sheetName, "XLSX_ENDPOINT_ROWS_SKIPPED", endpoint, "points with an invalid IED endpoint were skipped instead of guessed"); + if (rev3 && control.IssueStatus.Contains("REVIEW", StringComparison.OrdinalIgnoreCase)) + findings.Add(Finding(IoTestImportFindingSeverity.Warning, "DOCUMENT_CONTROL_REVIEW_REQUIRED", + "Document control is marked REVIEW REQUIRED. Confirm project and supplier identity before final customer issue.", sheet: DocumentSheet)); + + if (imported.Count == 0) { - parserFindings.Add(new IoTestImportFinding( - IoTestImportFindingSeverity.Error, - "XLSX_SIGNAL_ROWS_EMPTY", - "No SDI signal rows with TestPointId were found.", - SourceSheet: SignalSheetName)); - return BuildEmptyResult(sourceFileName, workbookBytes, parserFindings); + findings.Add(Finding(IoTestImportFindingSeverity.Error, "XLSX_SIGNAL_ROWS_EMPTY", + "No importable SDI signal rows with a valid identity were found.", sheet: sheetName)); + return Empty(sourceFileName, workbookBytes, findings); } - var projectIds = DistinctNonEmpty(pointRows, "ProjectId"); - var schemas = DistinctNonEmpty(pointRows, "SchemaVersion"); - if (projectIds.Count != 1) - parserFindings.Add(Error("XLSX_PROJECT_ID_INCONSISTENT", "The workbook must contain exactly one non-empty ProjectId.")); - if (schemas.Count != 1) - parserFindings.Add(Error("XLSX_SCHEMA_INCONSISTENT", "The workbook must contain exactly one non-empty SchemaVersion.")); - - var iedPlans = pointRows - .GroupBy(row => new IedKey(Get(row.Values, "IEDName"), Get(row.Values, "IPAddress")), IedKeyComparer.Instance) - .OrderBy(group => group.Key.IedName, StringComparer.OrdinalIgnoreCase) - .ThenBy(group => group.Key.IpAddress, StringComparer.OrdinalIgnoreCase) - .Select(group => BuildIedPlan(group.Key, group, parserFindings)) - .ToList(); + var projectIds = Distinct(imported, "ProjectId"); + var schemas = Distinct(imported, "SchemaVersion"); + if (projectIds.Count != 1) findings.Add(Finding(IoTestImportFindingSeverity.Error, "XLSX_PROJECT_ID_INCONSISTENT", "The workbook must contain exactly one ProjectId.", sheet: sheetName)); + if (schemas.Count != 1) findings.Add(Finding(IoTestImportFindingSeverity.Error, "XLSX_SCHEMA_INCONSISTENT", "The workbook must contain exactly one SchemaVersion.", sheet: sheetName)); + var ieds = imported + .GroupBy(x => new IedKey(Get(x.Values, "IEDName"), Get(x.Values, "IPAddress")), IedKeyComparer.Instance) + .OrderBy(x => x.Key.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => BuildIed(x.Key, x, findings)) + .ToList(); var project = new IoTestProject { ProjectId = projectIds.Count == 1 ? projectIds[0] : string.Empty, SchemaVersion = schemas.Count == 1 ? schemas[0] : string.Empty, - ProjectName = Path.GetFileNameWithoutExtension(sourceFileName), + ProjectName = projectName, SourceWorkbookName = sourceFileName, SourceWorkbookSha256 = Convert.ToHexString(SHA256.HashData(workbookBytes)).ToLowerInvariant(), ImportedAt = DateTimeOffset.UtcNow, - Ieds = iedPlans + DocumentControl = control, + Ieds = ieds }; project.InitializeRuntimeNotifications(); - - return new IoListExcelImportResult( - project, - _validator.Validate(project), - parserFindings, - pointRows.Count); + return new IoListExcelImportResult(project, _validator.Validate(project), findings, imported.Count); } - private static IoListExcelImportResult BuildEmptyResult( - string sourceFileName, - byte[] workbookBytes, - IReadOnlyList parserFindings) + private static Dictionary MapRev3(IReadOnlyDictionary source, string projectId) { - var project = new IoTestProject + var ready = Get(source, "Include in FAT").Equals("YES", StringComparison.OrdinalIgnoreCase) && + Get(source, "Review Status").Equals("READY", StringComparison.OrdinalIgnoreCase); + var eventRef = First(Get(source, "Event Log Search Reference"), Get(source, "IEC 61850 Reference (Source)")); + var displayRef = Get(source, "Report Display Reference"); + return new(StringComparer.OrdinalIgnoreCase) { - ProjectId = string.Empty, - SchemaVersion = string.Empty, - ProjectName = Path.GetFileNameWithoutExtension(sourceFileName), - SourceWorkbookName = sourceFileName, - SourceWorkbookSha256 = Convert.ToHexString(SHA256.HashData(workbookBytes)).ToLowerInvariant() + ["ProjectId"] = projectId, + ["SchemaVersion"] = IoTestImportValidator.SupportedSchemaVersion, + ["TestPointId"] = Get(source, "Point ID"), + ["TestEnabled"] = ready.ToString(CultureInfo.InvariantCulture), + ["ImportReady"] = ready.ToString(CultureInfo.InvariantCulture), + ["BindingStatus"] = Get(source, "Mapping Quality"), + ["BindingEvidence"] = Get(source, "Review Reason"), + ["SourceSheet"] = Get(source, "Source Sheet"), + ["SourceRow"] = Get(source, "Source Row"), + ["IEDName"] = Get(source, "IED Identifier / Technical Key"), + ["IPAddress"] = Get(source, "IP Address"), + ["IEDRole"] = Get(source, "IED Type"), + ["Location"] = Get(source, "Location"), + ["VoltageLevel"] = Get(source, "Voltage Level"), + ["Switchgear"] = Get(source, "Switchgear"), + ["SignalName"] = Get(source, "Signal Description"), + ["SignalAddress"] = Get(source, "Signal Alias"), + ["DataType"] = Get(source, "Data Type"), + ["LDInst"] = Get(source, "LD"), + ["LN"] = Get(source, "LN"), + ["FC"] = Get(source, "FC"), + ["DO"] = string.Empty, + ["DA"] = Get(source, "Data Attribute"), + ["CDC"] = Get(source, "CDC"), + ["ObjectReference"] = BindingReference(displayRef, Get(source, "LD"), eventRef, Get(source, "Data Attribute"), Get(source, "FC")), + ["SourceIecReference"] = Get(source, "IEC 61850 Reference (Source)"), + ["ReportDisplayReference"] = displayRef, + ["EventLogSearchReference"] = eventRef, + ["DataSetName"] = Get(source, "Dataset"), + ["ExpectedONRaw"] = "1", + ["ExpectedONText"] = Get(source, "State 1/10 Text (Source)"), + ["ExpectedOFFRaw"] = "0", + ["ExpectedOFFText"] = Get(source, "State 0/01 Text (Source)"), + ["EvidenceExpected"] = Get(source, "Evidence Expected"), + ["MappingQuality"] = Get(source, "Mapping Quality"), + ["ReviewStatus"] = Get(source, "Review Status"), + ["ReviewReason"] = Get(source, "Review Reason"), + ["EventLogMatch"] = Get(source, "Event Log Match"), + ["EvidenceReference"] = Get(source, "Evidence Ref / Screenshot"), + ["ReviewerComment"] = Get(source, "Reviewer Comment") }; - var validation = new IoTestImportValidator().Validate(project); - return new IoListExcelImportResult(project, validation, parserFindings, 0); } - private static IoTestIedPlan BuildIedPlan( - IedKey key, - IEnumerable sourceRows, - ICollection findings) + private static string BindingReference(string display, string ld, string eventRef, string da, string fc) { - var rows = sourceRows.ToList(); - var points = rows.Select(row => BuildPoint(key, row, findings)).ToList(); - return new IoTestIedPlan + if (!string.IsNullOrWhiteSpace(display)) { - IedName = key.IedName, - IpAddress = key.IpAddress, - IedRole = FirstNonEmpty(rows, "IEDRole"), - Location = JoinDistinct(rows, "Location"), - VoltageLevel = JoinDistinct(rows, "VoltageLevel"), - Switchgear = JoinDistinct(rows, "Switchgear"), - TestPoints = points - }; + var value = display.Trim(); + var suffix = $" [{fc.Trim()}]"; + return !string.IsNullOrWhiteSpace(fc) && value.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) + ? value[..^suffix.Length].TrimEnd() + : value; + } + var value2 = eventRef.Trim(); + if (!string.IsNullOrWhiteSpace(da) && !value2.EndsWith("." + da.Trim(), StringComparison.OrdinalIgnoreCase)) value2 += "." + da.Trim(); + if (!string.IsNullOrWhiteSpace(ld) && !value2.StartsWith(ld.Trim() + "/", StringComparison.OrdinalIgnoreCase)) value2 = ld.Trim() + "/" + value2.TrimStart('/'); + return value2; } - private static IoTestPointPlan BuildPoint( - IedKey key, - ImportedPointRow row, - ICollection findings) + private static IoFatDocumentControl ReadDocumentControl(ZipArchive archive, string workbookName, CancellationToken token) { - var values = row.Values; - var sourceRow = ParseInt(Get(values, "SourceRow"), row.RowNumber); - var onRaw = ParseInt(Get(values, "ExpectedONRaw"), 1); - var offRaw = ParseInt(Get(values, "ExpectedOFFRaw"), 0); - if (onRaw == offRaw) + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in ReadRows(archive, DocumentSheet, false, token).Skip(1)) { - findings.Add(new IoTestImportFinding( - IoTestImportFindingSeverity.Error, - "XLSX_EXPECTED_STATE_INVALID", - $"Expected ON and OFF raw values are identical for '{Get(values, "TestPointId")}'.", - Get(values, "TestPointId"), - Get(values, "SourceSheet"), - sourceRow)); + var key = row.Cells.TryGetValue(0, out var k) ? k.Trim() : string.Empty; + var value = row.Cells.TryGetValue(1, out var v) ? v.Trim() : string.Empty; + if (!string.IsNullOrWhiteSpace(key) && !values.ContainsKey(key)) values[key] = value; } - - return new IoTestPointPlan + var source = First(Get(values, "Source file name"), workbookName); + return new IoFatDocumentControl { - TestPointId = Get(values, "TestPointId"), - IedName = key.IedName, - IpAddress = key.IpAddress, - SignalName = Get(values, "SignalName"), - SignalAddress = Get(values, "SignalAddress"), - DataType = Get(values, "DataType"), - ObjectReference = Get(values, "ObjectReference"), - FunctionalConstraint = Get(values, "FC"), - LogicalDevice = Get(values, "LDInst"), - LogicalNode = Get(values, "LN"), - DataObject = Get(values, "DO"), - DataAttribute = Get(values, "DA"), - DataSetName = Get(values, "DataSetName"), - ExpectedOnRaw = onRaw, - ExpectedOnText = Get(values, "ExpectedONText"), - ExpectedOffRaw = offRaw, - ExpectedOffText = Get(values, "ExpectedOFFText"), - SourceSheet = Get(values, "SourceSheet"), - SourceRow = sourceRow, - TestEnabled = ParseBool(Get(values, "TestEnabled"), defaultValue: true), - ImportReady = ParseBool(Get(values, "ImportReady"), defaultValue: false), - BindingStatus = Get(values, "BindingStatus"), - BindingEvidence = Get(values, "BindingEvidence") + ClientProject = Get(values, "Project shown on front sheet"), + SupplierName = Get(values, "Supplier shown on front sheet"), + PurchaseOrderTitle = Get(values, "Purchase Order Title"), + PurchaserDocumentNumber = Get(values, "Purchaser Document No."), + CompanyProjectDocumentNumber = Get(values, "Company Project Document No."), + DocumentTitle = Get(values, "Document title"), + Revision = First(Get(values, "Document Revision"), ExtractRevision(source), Get(values, "Import revision")), + IssueStatus = Get(values, "Overall document-control status"), + SourceDocumentName = source }; } - private static Dictionary BuildRowValues( - WorksheetRow row, - IReadOnlyDictionary headerLookup) + private static string ExtractRevision(string value) { - var values = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var header in headerLookup) - values[header.Key] = row.Cells.TryGetValue(header.Value, out var value) ? value.Trim() : string.Empty; - return values; + var match = Regex.Match(value ?? string.Empty, @"_(?\d{2})_", RegexOptions.CultureInvariant); + return match.Success ? match.Groups["revision"].Value : string.Empty; } - private static List DistinctNonEmpty(IEnumerable rows, string key) - => rows.Select(row => Get(row.Values, key)) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.Ordinal) - .ToList(); - - private static string FirstNonEmpty(IEnumerable rows, string key) - => rows.Select(row => Get(row.Values, key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; - - private static string JoinDistinct(IEnumerable rows, string key) - => string.Join(", ", rows.Select(row => Get(row.Values, key)) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.OrdinalIgnoreCase)); + private static IoTestIedPlan BuildIed(IedKey key, IEnumerable rows, ICollection findings) + { + var list = rows.ToList(); + return new IoTestIedPlan + { + IedName = key.Name, + IpAddress = key.Ip, + IedRole = FirstValue(list, "IEDRole"), + Location = Join(list, "Location"), + VoltageLevel = Join(list, "VoltageLevel"), + Switchgear = Join(list, "Switchgear"), + TestPoints = list.Select(x => BuildPoint(key, x, findings)).ToList() + }; + } - private static string Get(IReadOnlyDictionary values, string key) - => values.TryGetValue(key, out var value) ? value.Trim() : string.Empty; + private static IoTestPointPlan BuildPoint(IedKey key, ImportedRow row, ICollection findings) + { + var v = row.Values; + var sourceRow = Int(Get(v, "SourceRow"), row.Number); + var on = Int(Get(v, "ExpectedONRaw"), 1); + var off = Int(Get(v, "ExpectedOFFRaw"), 0); + if (on == off) findings.Add(Finding(IoTestImportFindingSeverity.Error, "XLSX_EXPECTED_STATE_INVALID", + $"Expected ON and OFF values are identical for '{Get(v, "TestPointId")}'.", Get(v, "TestPointId"), Get(v, "SourceSheet"), sourceRow)); + return new IoTestPointPlan + { + TestPointId = Get(v, "TestPointId"), IedName = key.Name, IpAddress = key.Ip, + SignalName = Get(v, "SignalName"), SignalAddress = Get(v, "SignalAddress"), DataType = Get(v, "DataType"), + ObjectReference = Get(v, "ObjectReference"), FunctionalConstraint = Get(v, "FC"), + LogicalDevice = Get(v, "LDInst"), LogicalNode = Get(v, "LN"), DataObject = Get(v, "DO"), DataAttribute = Get(v, "DA"), + Cdc = Get(v, "CDC"), SourceIecReference = Get(v, "SourceIecReference"), ReportDisplayReference = Get(v, "ReportDisplayReference"), + EventLogSearchReference = Get(v, "EventLogSearchReference"), EvidenceExpected = Get(v, "EvidenceExpected"), + MappingQuality = Get(v, "MappingQuality"), ReviewStatus = Get(v, "ReviewStatus"), ReviewReason = Get(v, "ReviewReason"), + EventLogMatch = Get(v, "EventLogMatch"), EvidenceReference = Get(v, "EvidenceReference"), ReviewerComment = Get(v, "ReviewerComment"), + DataSetName = Get(v, "DataSetName"), ExpectedOnRaw = on, ExpectedOnText = Get(v, "ExpectedONText"), + ExpectedOffRaw = off, ExpectedOffText = Get(v, "ExpectedOFFText"), SourceSheet = Get(v, "SourceSheet"), SourceRow = sourceRow, + TestEnabled = Bool(Get(v, "TestEnabled"), true), ImportReady = Bool(Get(v, "ImportReady"), false), + BindingStatus = Get(v, "BindingStatus"), BindingEvidence = Get(v, "BindingEvidence") + }; + } - private static bool ParseBool(string value, bool defaultValue) + private static IoListExcelImportResult Empty(string file, byte[] bytes, IReadOnlyList findings) { - if (bool.TryParse(value, out var parsed)) - return parsed; - if (value == "1" || value.Equals("yes", StringComparison.OrdinalIgnoreCase) || value.Equals("x", StringComparison.OrdinalIgnoreCase)) - return true; - if (value == "0" || value.Equals("no", StringComparison.OrdinalIgnoreCase)) - return false; - return defaultValue; + var project = new IoTestProject + { + ProjectId = string.Empty, SchemaVersion = string.Empty, ProjectName = Path.GetFileNameWithoutExtension(file), + SourceWorkbookName = file, SourceWorkbookSha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant() + }; + return new IoListExcelImportResult(project, new IoTestImportValidator().Validate(project), findings, 0); } - private static int ParseInt(string value, int defaultValue) - => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : defaultValue; + private static Dictionary Headers(Row row, string sheet, ICollection findings) + { + var pairs = row.Cells.Where(x => !string.IsNullOrWhiteSpace(x.Value)).ToList(); + foreach (var duplicate in pairs.GroupBy(x => x.Value.Trim(), StringComparer.OrdinalIgnoreCase).Where(x => x.Count() > 1)) + findings.Add(Finding(IoTestImportFindingSeverity.Error, "XLSX_HEADER_DUPLICATE", $"Column header '{duplicate.Key}' occurs more than once.", sheet: sheet, row: row.Number)); + return pairs.GroupBy(x => x.Value.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First().Key, StringComparer.OrdinalIgnoreCase); + } - private static IoTestImportFinding Error(string code, string message) - => new(IoTestImportFindingSeverity.Error, code, message, SourceSheet: SignalSheetName); + private static Dictionary Values(Row row, IReadOnlyDictionary headers) + => headers.ToDictionary(x => x.Key, x => row.Cells.TryGetValue(x.Value, out var v) ? v.Trim() : string.Empty, StringComparer.OrdinalIgnoreCase); - private static IReadOnlyList ReadSheetRows( - ZipArchive archive, - string sheetName, - CancellationToken cancellationToken) + private static List ReadRows(ZipArchive archive, string sheet, bool required, CancellationToken token) { - 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."); - - var sheetPath = NormalizeWorkbookTarget(target); - var sharedStrings = ReadSharedStrings(archive); - var sheetDocument = LoadXml(archive, sheetPath); - var rows = new List(); - foreach (var row in sheetDocument.Descendants(SpreadsheetNs + "row")) + if (!TrySheetPath(archive, sheet, out var path)) { - cancellationToken.ThrowIfCancellationRequested(); - var rowNumber = (int?)row.Attribute("r") ?? rows.Count + 1; + if (required) throw new InvalidDataException($"Required sheet '{sheet}' was not found."); + return []; + } + var strings = SharedStrings(archive); + var result = new List(); + foreach (var row in Xml(archive, path).Descendants(S + "row")) + { + token.ThrowIfCancellationRequested(); var cells = new Dictionary(); - foreach (var cell in row.Elements(SpreadsheetNs + "c")) + foreach (var cell in row.Elements(S + "c")) { - var reference = (string?)cell.Attribute("r"); - var columnIndex = ColumnIndex(reference); - if (columnIndex < 0) - continue; - cells[columnIndex] = ReadCellValue(cell, sharedStrings); + var column = Column((string?)cell.Attribute("r")); + if (column >= 0) cells[column] = CellValue(cell, strings); } - if (cells.Count > 0) - rows.Add(new WorksheetRow(rowNumber, cells)); + if (cells.Count > 0) result.Add(new Row((int?)row.Attribute("r") ?? result.Count + 1, cells)); } - return rows; + return result; } - private static XDocument LoadXml(ZipArchive archive, string entryPath) + private static bool HasSheet(ZipArchive archive, string sheet) => TrySheetPath(archive, sheet, out _); + + private static bool TrySheetPath(ZipArchive archive, string sheetName, out string path) { - var entry = archive.GetEntry(entryPath) ?? throw new InvalidDataException($"XLSX entry '{entryPath}' is missing."); - using var stream = entry.Open(); + var workbook = Xml(archive, "xl/workbook.xml"); + var relationships = Xml(archive, "xl/_rels/workbook.xml.rels"); + var sheet = workbook.Root?.Element(S + "sheets")?.Elements(S + "sheet") + .FirstOrDefault(x => string.Equals((string?)x.Attribute("name"), sheetName, StringComparison.OrdinalIgnoreCase)); + if (sheet == null) { path = string.Empty; return false; } + var id = (string?)sheet.Attribute(R + "id"); + var target = (string?)relationships.Root?.Elements(P + "Relationship") + .FirstOrDefault(x => string.Equals((string?)x.Attribute("Id"), id, StringComparison.Ordinal))?.Attribute("Target"); + if (string.IsNullOrWhiteSpace(target)) throw new InvalidDataException($"Sheet '{sheetName}' target could not be resolved."); + path = NormalizeTarget(target); + return true; + } + + private static XDocument Xml(ZipArchive archive, string path) + { + using var stream = (archive.GetEntry(path) ?? throw new InvalidDataException($"XLSX entry '{path}' is missing.")).Open(); return XDocument.Load(stream, LoadOptions.None); } - private static IReadOnlyList ReadSharedStrings(ZipArchive archive) + private static IReadOnlyList SharedStrings(ZipArchive archive) { var entry = archive.GetEntry("xl/sharedStrings.xml"); - if (entry == null) - return Array.Empty(); + if (entry == null) return []; 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(); + return XDocument.Load(stream).Descendants(S + "si").Select(x => string.Concat(x.Descendants(S + "t").Select(t => t.Value))).ToList(); } - private static string ReadCellValue(XElement cell, IReadOnlyList sharedStrings) + private static string CellValue(XElement cell, IReadOnlyList strings) { 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; + if (type == "inlineStr") return string.Concat(cell.Descendants(S + "t").Select(x => x.Value)); + var raw = cell.Element(S + "v")?.Value ?? string.Empty; + if (type == "s" && int.TryParse(raw, out var index) && index >= 0 && index < strings.Count) return strings[index]; + return type == "b" ? raw == "1" ? "true" : "false" : raw; } - private static int ColumnIndex(string? cellReference) + private static int Column(string? reference) { - if (string.IsNullOrWhiteSpace(cellReference)) - return -1; - var index = 0; - var found = false; - foreach (var ch in cellReference) - { - if (!char.IsLetter(ch)) - break; - found = true; - index = checked(index * 26 + (char.ToUpperInvariant(ch) - 'A' + 1)); - } - return found ? index - 1 : -1; + if (string.IsNullOrWhiteSpace(reference)) return -1; + var value = 0; + foreach (var ch in reference.TakeWhile(char.IsLetter)) value = checked(value * 26 + char.ToUpperInvariant(ch) - 'A' + 1); + return value - 1; } - private static string NormalizeWorkbookTarget(string target) + private static string NormalizeTarget(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('/'); + var value = target.Replace('\\', '/').TrimStart('/'); + while (value.StartsWith("../", StringComparison.Ordinal)) value = value[3..]; + return value.StartsWith("xl/", StringComparison.OrdinalIgnoreCase) ? value : "xl/" + value; } - private sealed record WorksheetRow(int RowNumber, IReadOnlyDictionary Cells); - private sealed record ImportedPointRow(int RowNumber, IReadOnlyDictionary Values); - private sealed record IedKey(string IedName, string IpAddress); + private static void AddSkip(ICollection findings, string sheet, string code, int count, string text) + { + if (count > 0) findings.Add(Finding(IoTestImportFindingSeverity.Warning, code, $"{count:N0} {text}.", sheet: sheet)); + } + private static IoTestImportFinding Finding(IoTestImportFindingSeverity severity, string code, string message, + string? point = null, string? sheet = null, int? row = null) => new(severity, code, message, point, sheet, row); + private static string Get(IReadOnlyDictionary values, string key) => values.TryGetValue(key, out var value) ? value.Trim() : string.Empty; + private static string First(params string?[] values) => values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? string.Empty; + private static List Distinct(IEnumerable rows, string key) => rows.Select(x => Get(x.Values, key)).Where(x => x.Length > 0).Distinct(StringComparer.Ordinal).ToList(); + private static string FirstValue(IEnumerable rows, string key) => rows.Select(x => Get(x.Values, key)).FirstOrDefault(x => x.Length > 0) ?? string.Empty; + private static string Join(IEnumerable rows, string key) => string.Join(", ", rows.Select(x => Get(x.Values, key)).Where(x => x.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase)); + private static bool Bool(string value, bool fallback) => bool.TryParse(value, out var result) ? result : value is "1" or "yes" or "YES" or "x" or "X" ? true : value is "0" or "no" or "NO" ? false : fallback; + private static int Int(string value, int fallback) => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : fallback; + + private sealed record Row(int Number, IReadOnlyDictionary Cells); + private sealed record ImportedRow(int Number, IReadOnlyDictionary Values); + private sealed record IedKey(string Name, string Ip); private sealed class IedKeyComparer : IEqualityComparer { public static IedKeyComparer Instance { get; } = new(); - public bool Equals(IedKey? x, IedKey? y) - => x != null && y != null && - string.Equals(x.IedName, y.IedName, StringComparison.OrdinalIgnoreCase) && - string.Equals(x.IpAddress, y.IpAddress, StringComparison.OrdinalIgnoreCase); - public int GetHashCode(IedKey obj) - => HashCode.Combine(obj.IedName.ToUpperInvariant(), obj.IpAddress.ToUpperInvariant()); + public bool Equals(IedKey? x, IedKey? y) => x != null && y != null && x.Name.Equals(y.Name, StringComparison.OrdinalIgnoreCase) && x.Ip.Equals(y.Ip, StringComparison.OrdinalIgnoreCase); + public int GetHashCode(IedKey obj) => HashCode.Combine(obj.Name.ToUpperInvariant(), obj.Ip.ToUpperInvariant()); } } From 65acad573bc74db901be131a25438ad0f8134a71 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 15:04:43 +0700 Subject: [PATCH 17/56] Add document-controlled event-log FAT report layout --- .../IoFatExecutiveReportLayoutEngine.cs | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs diff --git a/Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs b/Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs new file mode 100644 index 000000000..fac571d5e --- /dev/null +++ b/Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs @@ -0,0 +1,510 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using System.Text; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Customer-facing IEC 61850 FAT report with document control, exact telegram +/// traceability, relay timestamps, acceptance summary, and handover sign-off. +/// +internal static class IoFatExecutiveReportLayoutEngine +{ + public const double PageWidth = 842d; + public const double PageHeight = 595d; + + private const double Margin = 30d; + private const double HeaderBottom = 496d; + private const double ContentTop = 480d; + private const double ContentBottom = 55d; + private const double ContentWidth = PageWidth - (Margin * 2d); + + private static readonly IoFatReportColor Navy = Color("0F172A"); + private static readonly IoFatReportColor Blue = Color("2563EB"); + private static readonly IoFatReportColor SoftBlue = Color("EFF6FF"); + private static readonly IoFatReportColor Border = Color("D9E4F0"); + private static readonly IoFatReportColor SoftLine = Color("EDF2F7"); + private static readonly IoFatReportColor Muted = Color("64748B"); + private static readonly IoFatReportColor Ink = Color("1F2937"); + private static readonly IoFatReportColor White = Color("FFFFFF"); + private static readonly IoFatReportColor Pass = Color("15803D"); + private static readonly IoFatReportColor Attention = Color("B45309"); + private static readonly IoFatReportColor Fail = Color("B91C1C"); + private static readonly IoFatReportColor SoftPass = Color("F0FDF4"); + private static readonly IoFatReportColor SoftAttention = Color("FFFBEB"); + private static readonly IoFatReportColor SoftFail = Color("FEF2F2"); + + public static IoFatReportLayoutPlan Build(IoTestProject project, DateTimeOffset created, bool draft = false) + { + ArgumentNullException.ThrowIfNull(project); + return new Builder(project, created, draft).Render(); + } + + private sealed class Builder + { + private readonly IoTestProject _project; + private readonly DateTimeOffset _created; + private readonly bool _draft; + private readonly List _pages = new(); + private PageBuilder _page = null!; + private double _cursorY; + + public Builder(IoTestProject project, DateTimeOffset created, bool draft) + { + _project = project; + _created = created; + _draft = draft; + } + + public IoFatReportLayoutPlan Render() + { + NewPage(); + foreach (var ied in _project.Ieds) + DrawIedSection(ied); + + if (_project.Ieds.Count == 0) + DrawEmptyProjectNotice(); + else + DrawCloseout(); + + var totalPages = _pages.Count; + for (var index = 0; index < totalPages; index++) + DrawPageChrome(_pages[index], index + 1, totalPages); + + return new IoFatReportLayoutPlan( + _project.ProjectId, + _created, + _draft, + _pages.Select((page, index) => + new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, page.Commands.ToArray())) + .ToArray()); + } + + private void NewPage() + { + _page = new PageBuilder(); + _pages.Add(_page); + _cursorY = ContentTop; + } + + private void Ensure(double requiredHeight) + { + if (_cursorY - requiredHeight < ContentBottom) + NewPage(); + } + + private void DrawPageChrome(PageBuilder page, int pageNumber, int totalPages) + { + var control = _project.DocumentControl; + var projectName = FirstNonEmpty(control.ClientProject, _project.ProjectName, _project.ProjectId); + var documentNumber = FirstNonEmpty( + control.PurchaserDocumentNumber, + control.CompanyProjectDocumentNumber, + _project.ProjectId); + var revision = FirstNonEmpty(control.Revision, "-"); + var issueStatus = FirstNonEmpty(control.IssueStatus, _draft ? "DRAFT / LIVE" : "AS TESTED"); + var supplier = FirstNonEmpty(control.SupplierName, "Supplier not stated"); + var poTitle = FirstNonEmpty(control.PurchaseOrderTitle, control.DocumentTitle); + + page.Line(Margin, HeaderBottom, PageWidth - Margin, HeaderBottom, Border, 0.8d); + page.Text(Margin, 566d, 480d, Clean(projectName), IoFatReportFontKind.Bold, 7.2d, Muted); + page.Text(Margin, 542d, 500d, "IEC 61850 FAT Evidence Report", IoFatReportFontKind.Bold, 20.2d, Navy); + page.Text(Margin, 522d, 500d, "Signal-state verification with IED timestamps and event-log traceability.", IoFatReportFontKind.Regular, 8.1d, Muted); + page.Text(Margin, 505d, 520d, BuildSupplierLine(supplier, poTitle), IoFatReportFontKind.Regular, 6.6d, Muted); + + const double cardWidth = 222d; + const double cardHeight = 67d; + var cardX = PageWidth - Margin - cardWidth; + const double cardTop = 569d; + var reviewIssue = issueStatus.Contains("REVIEW", StringComparison.OrdinalIgnoreCase) || _draft; + var statusColor = reviewIssue ? Attention : Blue; + var statusBackground = reviewIssue ? SoftAttention : SoftBlue; + + page.RoundRect(cardX, cardTop, cardWidth, cardHeight, 6d, statusBackground, statusColor, 0.8d); + page.Text(cardX + 11d, cardTop - 14d, cardWidth - 22d, "DOCUMENT CONTROL", IoFatReportFontKind.Bold, 5.9d, Muted); + page.Text(cardX + 11d, cardTop - 30d, cardWidth - 22d, Clean(documentNumber), IoFatReportFontKind.Bold, 8.2d, Navy); + page.Text(cardX + 11d, cardTop - 45d, cardWidth - 22d, $"REV {Clean(revision)} | {Clean(issueStatus)}", IoFatReportFontKind.Bold, 6.7d, statusColor); + page.Text(cardX + 11d, cardTop - 58d, cardWidth - 22d, _draft ? "PREVIEW - NOT FOR ISSUE" : "CUSTOMER FAT RECORD", IoFatReportFontKind.Regular, 5.8d, Muted); + + page.Line(Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); + page.Text(Margin, 24d, 540d, + $"Generated {_created:yyyy-MM-dd HH:mm:ss zzz} | IED timestamp format yyyy-MM-dd HH:mm:ss.fff", + IoFatReportFontKind.Regular, 6.1d, Muted); + page.Text(PageWidth - Margin - 118d, 24d, 118d, $"Page {pageNumber} / {totalPages}", IoFatReportFontKind.Regular, 6.1d, Muted); + } + + private void DrawIedSection(IoTestIedPlan ied) + { + var points = ReportPoints(ied); + Ensure(82d); + DrawIedHeader(ied, points, continued: false); + DrawTableHeader(); + + var rowNumber = 0; + foreach (var point in points) + { + rowNumber++; + var cells = BuildCells(point, rowNumber); + var rowHeight = EstimateRowHeight(cells); + if (_cursorY - rowHeight < ContentBottom) + { + NewPage(); + DrawIedHeader(ied, points, continued: true); + DrawTableHeader(); + } + DrawRow(cells, rowHeight); + } + + if (points.Count == 0) + { + Ensure(34d); + _page.RoundRect(Margin, _cursorY, ContentWidth, 28d, 4d, SoftAttention, Border, 0.6d); + _page.Text(Margin + 10d, _cursorY - 18d, ContentWidth - 20d, + "No signal is currently selected or completed for this device.", + IoFatReportFontKind.Regular, 7.2d, Attention); + _cursorY -= 38d; + } + _cursorY -= 10d; + } + + private void DrawIedHeader(IoTestIedPlan ied, IReadOnlyList points, bool continued) + { + var title = continued ? $"{ied.IedName} (continued)" : ied.IedName; + 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); + var pending = Math.Max(0, points.Count - passed - review - failed); + var status = $"{passed} OF {points.Count} SIGNALS PASSED"; + var statusColor = failed > 0 ? Fail : review > 0 || pending > 0 ? Attention : Pass; + var statusBackground = failed > 0 ? SoftFail : review > 0 || pending > 0 ? SoftAttention : SoftPass; + const double height = 50d; + + _page.RoundRect(Margin, _cursorY, ContentWidth, height, 6d, White, Border, 0.7d); + _page.Rect(Margin, _cursorY, 4d, height, statusColor, statusColor, 0d); + _page.Text(Margin + 14d, _cursorY - 18d, 390d, Clean(title), IoFatReportFontKind.Bold, 10.7d, Navy); + _page.Text(Margin + 14d, _cursorY - 35d, 500d, BuildDeviceMeta(ied), IoFatReportFontKind.Regular, 6.9d, Muted); + + const double badgeWidth = 188d; + var badgeX = PageWidth - Margin - badgeWidth - 10d; + _page.RoundRect(badgeX, _cursorY - 9d, badgeWidth, 30d, 5d, statusBackground, statusColor, 0.7d); + _page.Text(badgeX + 10d, _cursorY - 21d, badgeWidth - 20d, "DEVICE RESULT", IoFatReportFontKind.Bold, 5.6d, Muted); + _page.Text(badgeX + 10d, _cursorY - 35d, badgeWidth - 20d, status, IoFatReportFontKind.Bold, 8.1d, statusColor); + _cursorY -= height + 7d; + } + + private void DrawTableHeader() + { + Ensure(24d); + var widths = ColumnWidths(); + var headers = new[] + { + "#", "Signal", "IEC 61850 / event-log reference", "Expected state", + "TRUE IED timestamp", "FALSE IED timestamp", "Result" + }; + var x = Margin; + const double height = 22d; + for (var index = 0; index < headers.Length; index++) + { + _page.Rect(x, _cursorY, widths[index], height, SoftBlue, Border, 0.45d); + _page.Text(x + 5d, _cursorY - 14.5d, widths[index] - 10d, + headers[index], IoFatReportFontKind.Bold, 5.8d, Blue); + x += widths[index]; + } + _cursorY -= height; + } + + private void DrawRow(IReadOnlyList cells, double 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.35d); + var lines = WrapText(cell.Text, widths[index] - 10d, cell.FontSize); + var y = _cursorY - 10d; + foreach (var line in lines) + { + _page.Text(x + 5d, y, widths[index] - 10d, line, cell.Font, cell.FontSize, cell.Color); + y -= cell.FontSize + 1.7d; + } + x += widths[index]; + } + _cursorY -= rowHeight; + } + + private static ReportCell[] BuildCells(IoTestPointPlan point, int rowNumber) + { + var stateColor = ResolvePointColor(point.Runtime.State); + return + [ + new ReportCell(rowNumber.ToString(CultureInfo.InvariantCulture), IoFatReportFontKind.Mono, 5.8d, Ink), + new ReportCell(point.SignalName, IoFatReportFontKind.Bold, 6.4d, Ink), + new ReportCell(point.ReportIecReference, IoFatReportFontKind.Mono, 5.65d, Ink), + new ReportCell(ExpectedStateText(point), IoFatReportFontKind.Regular, 6.0d, Ink), + new ReportCell(RelayTime(point.Runtime.OnEvidence), IoFatReportFontKind.Mono, 5.85d, Ink), + new ReportCell(RelayTime(point.Runtime.OffEvidence), IoFatReportFontKind.Mono, 5.85d, Ink), + new ReportCell(ResultText(point.Runtime.State), IoFatReportFontKind.Bold, 6.3d, stateColor) + ]; + } + + private static double EstimateRowHeight(IReadOnlyList cells) + { + var widths = ColumnWidths(); + var maximum = 1; + for (var index = 0; index < cells.Count; index++) + maximum = Math.Max(maximum, WrapText(cells[index].Text, widths[index] - 10d, cells[index].FontSize).Count); + return Math.Max(29d, 10d + (maximum * 7.8d)); + } + + private static double[] ColumnWidths() => [24d, 178d, 218d, 108d, 94d, 94d, 66d]; + + private void DrawCloseout() + { + Ensure(116d); + var counts = Counts(_project); + var exceptionText = counts.Failed > 0 + ? $"{counts.Failed} failed signal(s)" + : counts.Review > 0 + ? $"{counts.Review} signal(s) require review" + : counts.Pending > 0 + ? $"{counts.Pending} signal(s) not completed" + : "None - all reported signals passed"; + + const double summaryHeight = 42d; + _page.RoundRect(Margin, _cursorY, ContentWidth, summaryHeight, 5d, SoftBlue, Border, 0.7d); + _page.Text(Margin + 12d, _cursorY - 14d, 150d, "TEST BASIS", IoFatReportFontKind.Bold, 5.8d, Muted); + _page.Text(Margin + 12d, _cursorY - 29d, 350d, + "Sequence: FALSE > TRUE > FALSE | Timestamp source: IED report/event", + IoFatReportFontKind.Regular, 6.6d, Ink); + _page.Text(Margin + 390d, _cursorY - 14d, 150d, "EVENT-LOG CORRELATION", IoFatReportFontKind.Bold, 5.8d, Muted); + _page.Text(Margin + 390d, _cursorY - 29d, 365d, + "Match key: exact IEC 61850 reference | Exceptions: " + exceptionText, + IoFatReportFontKind.Regular, 6.6d, Ink); + _cursorY -= summaryHeight + 8d; + + const double signHeight = 48d; + var signWidth = ContentWidth / 4d; + var labels = new[] { "TESTED BY", "CHECKED BY", "CLIENT WITNESS", "APPROVED BY" }; + for (var index = 0; index < labels.Length; index++) + { + var x = Margin + (index * signWidth); + _page.Rect(x, _cursorY, signWidth, signHeight, White, Border, 0.55d); + _page.Text(x + 8d, _cursorY - 13d, signWidth - 16d, labels[index], IoFatReportFontKind.Bold, 5.8d, Muted); + _page.Line(x + 8d, _cursorY - 30d, x + signWidth - 8d, _cursorY - 30d, Border, 0.55d); + _page.Text(x + 8d, _cursorY - 42d, signWidth - 16d, "Name / signature / date", IoFatReportFontKind.Regular, 5.7d, Muted); + } + _cursorY -= signHeight + 8d; + } + + private void DrawEmptyProjectNotice() + { + Ensure(48d); + _page.RoundRect(Margin, _cursorY, ContentWidth, 42d, 5d, SoftAttention, Border, 0.7d); + _page.Text(Margin + 12d, _cursorY - 25d, ContentWidth - 24d, + "No device test plan is present in this project.", IoFatReportFontKind.Bold, 9d, Attention); + _cursorY -= 52d; + } + } + + private sealed record ReportCell(string Text, IoFatReportFontKind Font, double FontSize, IoFatReportColor Color); + + private sealed class PageBuilder + { + public List Commands { get; } = new(); + + public void Text(double x, double baselineY, double width, string text, IoFatReportFontKind font, double size, IoFatReportColor color) + { + var safe = SanitizeReportText(text); + if (safe.Length > 0) + Commands.Add(new IoFatReportTextCommand(x, baselineY, Math.Max(4d, width), safe, font, size, color)); + } + + public void Line(double x1, double y1, double x2, double y2, IoFatReportColor stroke, double width) + => Commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, width)); + + public void Rect(double x, double top, double width, double height, IoFatReportColor fill, IoFatReportColor stroke, double lineWidth) + => Commands.Add(new IoFatReportRectCommand(x, top, width, height, 0d, fill, stroke, lineWidth)); + + public void RoundRect(double x, double top, double width, double height, double radius, IoFatReportColor fill, IoFatReportColor stroke, double lineWidth) + => Commands.Add(new IoFatReportRectCommand(x, top, width, height, radius, fill, stroke, lineWidth)); + } + + private readonly record struct ProjectCounts(int Passed, int Review, int Failed, int Pending); + + private static List ReportPoints(IoTestIedPlan ied) + => ied.TestPoints.Where(point => point.TestEnabled || point.Runtime.IsComplete).ToList(); + + private static ProjectCounts Counts(IoTestProject project) + { + var points = project.Ieds.SelectMany(ReportPoints).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 IoFatReportColor ResolvePointColor(IoTestPointState state) => state switch + { + IoTestPointState.Passed => Pass, + IoTestPointState.Failed => Fail, + IoTestPointState.Review => Attention, + _ => Muted + }; + + private static string ResultText(IoTestPointState state) => state switch + { + IoTestPointState.Passed => "PASS", + IoTestPointState.Review => "REVIEW", + IoTestPointState.Failed => "FAILED", + _ => "PENDING" + }; + + private static string ExpectedStateText(IoTestPointPlan point) + { + var trueLabel = NormalizeExpectedLabel(point.ExpectedOnText, "ON", "True"); + var falseLabel = NormalizeExpectedLabel(point.ExpectedOffText, "OFF", "False"); + return $"{trueLabel} (True)\n{falseLabel} (False)"; + } + + private static string NormalizeExpectedLabel(string? value, string prefix, string booleanText) + { + var clean = Clean(value); + foreach (var suffix in new[] { " (1)", " (0)", " (True)", " (False)" }) + { + if (clean.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + clean = clean[..^suffix.Length].Trim(); + break; + } + } + + if (clean.Equals("1", StringComparison.OrdinalIgnoreCase) || clean.Equals("true", StringComparison.OrdinalIgnoreCase)) + return "ON"; + if (clean.Equals("0", StringComparison.OrdinalIgnoreCase) || clean.Equals("false", StringComparison.OrdinalIgnoreCase)) + return "OFF"; + + var repeatedPrefix = prefix + " "; + if (clean.StartsWith(repeatedPrefix, StringComparison.OrdinalIgnoreCase)) + clean = clean[repeatedPrefix.Length..].Trim(); + return string.IsNullOrWhiteSpace(clean) || clean == "-" ? booleanText : clean; + } + + private static string RelayTime(IoTestTransitionEvidence? evidence) + => evidence?.IedTimestamp?.ToString("yyyy-MM-dd\nHH:mm:ss.fff", CultureInfo.InvariantCulture) ?? "-"; + + private static string BuildDeviceMeta(IoTestIedPlan ied) + { + var values = new List(); + if (!string.IsNullOrWhiteSpace(ied.IpAddress)) values.Add(ied.IpAddress.Trim()); + if (!string.IsNullOrWhiteSpace(ied.IedRole)) values.Add(ied.IedRole.Trim()); + if (!string.IsNullOrWhiteSpace(ied.Location)) values.Add(ied.Location.Trim()); + if (!string.IsNullOrWhiteSpace(ied.VoltageLevel)) values.Add(ied.VoltageLevel.Trim()); + if (!string.IsNullOrWhiteSpace(ied.Switchgear)) values.Add(ied.Switchgear.Trim()); + return values.Count == 0 ? "Device details not supplied" : string.Join(" | ", values); + } + + private static string BuildSupplierLine(string supplier, string poTitle) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(supplier)) parts.Add("Supplier: " + supplier.Trim()); + if (!string.IsNullOrWhiteSpace(poTitle) && poTitle != "-") parts.Add("Package: " + poTitle.Trim()); + return parts.Count == 0 ? string.Empty : string.Join(" | ", parts); + } + + private static IReadOnlyList WrapText(string? value, double width, double fontSize) + { + var input = (value ?? string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + if (string.IsNullOrWhiteSpace(input)) + return ["-"]; + + var charsPerLine = Math.Max(7, (int)Math.Floor(width / Math.Max(2.4d, fontSize * 0.49d))); + var lines = new List(); + foreach (var paragraphValue in input.Split('\n')) + { + var paragraph = SanitizeReportText(paragraphValue); + if (paragraph.Length == 0) + { + lines.Add("-"); + continue; + } + + 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(); + } + lines.Add(word[..charsPerLine]); + word = word[charsPerLine..]; + } + + if (word.Length == 0) + continue; + 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 (current.Length > 0) + lines.Add(current.ToString()); + } + return lines.Count == 0 ? ["-"] : lines; + } + + internal static string SanitizeReportText(string? value) + { + var normalized = (value ?? string.Empty) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal) + .Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + return "-"; + + var builder = new StringBuilder(normalized.Length); + foreach (var character in normalized) + { + builder.Append(character switch + { + '\u2013' or '\u2014' or '\u2212' => '-', + '\u2192' => '>', + '\u2190' => '<', + '\u00B7' => '|', + '\u00A0' => ' ', + >= ' ' and <= '~' => character, + _ => ' ' + }); + } + return builder.ToString().Trim(); + } + + 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 FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + + private static IoFatReportColor Color(string hex) => IoFatReportColor.FromHex(hex); +} From 392f1021fb303ea271955cfb67b9205f0303d45d Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 15:05:02 +0700 Subject: [PATCH 18/56] Use executive FAT layout for native PDF --- Services/IoTesting/IoFatPdfReportService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/IoTesting/IoFatPdfReportService.cs b/Services/IoTesting/IoFatPdfReportService.cs index a251b2fff..8e3473ab9 100644 --- a/Services/IoTesting/IoFatPdfReportService.cs +++ b/Services/IoTesting/IoFatPdfReportService.cs @@ -24,7 +24,7 @@ internal static IoFatReportLayoutPlan BuildLayout( bool draft = false) { ArgumentNullException.ThrowIfNull(project); - return IoFatReportLayoutEngine.Build(project, generatedAt ?? DateTimeOffset.Now, draft); + return IoFatExecutiveReportLayoutEngine.Build(project, generatedAt ?? DateTimeOffset.Now, draft); } public static void Save(string fileName, IoTestProject project, DateTimeOffset? generatedAt = null) From c719f37b241d3e916fa7698d5f079c9236ab4fd8 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 15:05:20 +0700 Subject: [PATCH 19/56] Preserve document control in selected IED preview --- Services/IoTesting/IoFatReportPreviewService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Services/IoTesting/IoFatReportPreviewService.cs b/Services/IoTesting/IoFatReportPreviewService.cs index 8c15cb3de..3f9be5c74 100644 --- a/Services/IoTesting/IoFatReportPreviewService.cs +++ b/Services/IoTesting/IoFatReportPreviewService.cs @@ -26,6 +26,7 @@ public static IoTestProject CreateIedScopedProject(IoTestProject project, IoTest SourceWorkbookName = project.SourceWorkbookName, SourceWorkbookSha256 = project.SourceWorkbookSha256, ImportedAt = project.ImportedAt, + DocumentControl = project.DocumentControl, Ieds = new List { ied } }; } From 79b638a4cf3a60f71d89586c1a75c9de06c3d341 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 15:05:57 +0700 Subject: [PATCH 20/56] Use executive layout in native print preview --- Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs index 482889fc5..d0e43741a 100644 --- a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs +++ b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs @@ -27,7 +27,8 @@ public static FixedDocument Build( DateTimeOffset? generatedAt = null) { ArgumentNullException.ThrowIfNull(project); - var layout = IoFatReportLayoutEngine.Build(project, generatedAt ?? DateTimeOffset.Now, draft); + // Replaces the legacy IoFatReportLayoutEngine.Build route with the document-controlled event-log layout. + var layout = IoFatExecutiveReportLayoutEngine.Build(project, generatedAt ?? DateTimeOffset.Now, draft); return Render(layout); } @@ -133,9 +134,6 @@ private static void AddText(FixedPage page, double pageHeight, IoFatReportTextCo block.SetValue(TextOptions.TextFormattingModeProperty, TextFormattingMode.Ideal); block.SetValue(TextOptions.TextRenderingModeProperty, TextRenderingMode.ClearType); - // Downscale only when the actual Windows font metrics are wider than the - // shared PDF-point estimate. This keeps PASS and timestamps complete while - // preserving the intended size whenever they already fit. var textPresenter = new Viewbox { Child = block, From 73b49b90b7c3c5f4905a66cbfaf2d695fa0c389b Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 15:06:53 +0700 Subject: [PATCH 21/56] Test Rev3 event-log-ready workbook import --- .../ARSAS.Tests/IoFatRev3ExcelImportTests.cs | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/ARSAS.Tests/IoFatRev3ExcelImportTests.cs diff --git a/tests/ARSAS.Tests/IoFatRev3ExcelImportTests.cs b/tests/ARSAS.Tests/IoFatRev3ExcelImportTests.cs new file mode 100644 index 000000000..a6ded5b50 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatRev3ExcelImportTests.cs @@ -0,0 +1,187 @@ +using System.IO.Compression; +using System.Security; +using System.Text; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatRev3ExcelImportTests +{ + private readonly IoListExcelImportService _importer = new(); + + [Fact] + public void EventLogReadyWorkbook_ImportsReadySdiAndPreservesDocumentTraceability() + { + var headers = Rev3Headers(); + var points = new[] + { + Row(headers, + ("Point ID", "UCC-IEC-0001"), ("Include in FAT", "YES"), + ("Source Sheet", "SoftTags_IEC61850"), ("Source Row", "216"), + ("Location", "ELECT-CCPP"), ("Voltage Level", "66kV"), + ("Switchgear", "261-SWG-51001"), ("Signal Description", "CB opened"), + ("IED Type", "BCU - 6MD85"), ("IED Identifier / Technical Key", "AA1C1F03R4"), + ("IP Address", "192.168.81.70"), ("Signal Alias", "CBOpnd"), + ("Data Type", "SDI"), ("CDC", "SPS"), ("LD", "AA1C1F03R4Application"), + ("LN", "GGIO6"), ("FC", "ST"), + ("IEC 61850 Reference (Source)", "ADD/GGIO6.CBOpnd"), + ("Data Attribute", "stVal"), + ("Report Display Reference", "AA1C1F03R4Application/ADD/GGIO6.CBOpnd.stVal [ST]"), + ("Event Log Search Reference", "ADD/GGIO6.CBOpnd"), + ("State 0/01 Text (Source)", "Normal"), + ("State 1/10 Text (Source)", "Operated"), + ("Evidence Expected", "Event Log"), ("Mapping Quality", "COMPLETE"), + ("Review Status", "READY"), ("Review Reason", "No blocking issue detected")), + Row(headers, + ("Point ID", "UCC-IEC-0002"), ("Include in FAT", "YES"), + ("Source Sheet", "SoftTags_IEC61850"), ("Source Row", "262"), + ("Signal Description", "Phase L1-L2 Voltage"), + ("IED Identifier / Technical Key", "AA1C1F03R4"), + ("IP Address", "192.168.81.70"), ("Data Type", "SAI"), + ("FC", "MX"), ("IEC 61850 Reference (Source)", "V_MMXU1.PhV.phsAB"), + ("State 0/01 Text (Source)", "Normal"), + ("State 1/10 Text (Source)", "Operated"), ("Review Status", "READY")), + Row(headers, + ("Point ID", "UT2-IEC-0001"), ("Include in FAT", "REVIEW"), + ("Source Sheet", "SoftTags_IEC61850 (Utility-2)"), ("Source Row", "5"), + ("Signal Description", "CB Open command from PMS"), + ("IED Identifier / Technical Key", "REX615A"), ("IP Address", "NA"), + ("Data Type", "SDI"), ("FC", "TBA"), + ("IEC 61850 Reference (Source)", "TBA"), + ("State 0/01 Text (Source)", "Normal"), + ("State 1/10 Text (Source)", "Operated"), + ("Review Status", "BLOCKED – REFERENCE MISSING")) + }; + + var documentControl = new[] + { + new[] { "Document Control Item", "Value", "Assessment" }, + new[] { "Source file name", "V-2181-801-A-EIC-154_02_Source.xlsx", "" }, + new[] { "Project shown on front sheet", "Tangguh UCC Project - Onshore EPCI", "" }, + new[] { "Supplier shown on front sheet", "PT ABB Sakti Industri", "" }, + new[] { "Purchase Order Title", "Power Management System (PMS)", "" }, + new[] { "Purchaser Document No.", "V-2181-801-A-EIC-154", "" }, + new[] { "Company Project Document No.", "UCC-2181801A-E99-0001", "" }, + new[] { "Document title", "PMS Hard Tag and Soft Tag List", "" }, + new[] { "Overall document-control status", "REVIEW REQUIRED", "" } + }; + var workbook = BuildWorkbook(headers, points, documentControl); + + var result = _importer.Import(workbook, "IEC61850_FAT_Import_Rev3_EventLogReady.xlsx"); + + Assert.True(result.IsValid); + Assert.Equal(1, result.ParsedRowCount); + Assert.Single(result.Project.Ieds); + Assert.Equal("UCC-2181801A-E99-0001", result.Project.ProjectId); + Assert.Equal("Tangguh UCC Project - Onshore EPCI", result.Project.ProjectName); + Assert.Equal("02", result.Project.DocumentControl.Revision); + Assert.Equal("PT ABB Sakti Industri", result.Project.DocumentControl.SupplierName); + Assert.Equal("REVIEW REQUIRED", result.Project.DocumentControl.IssueStatus); + + var point = Assert.Single(result.Project.Ieds[0].TestPoints); + Assert.Equal("ADD/GGIO6.CBOpnd", point.SourceIecReference); + Assert.Equal("ADD/GGIO6.CBOpnd", point.EventLogSearchReference); + Assert.Equal("ADD/GGIO6.CBOpnd", point.ReportIecReference); + Assert.Equal("AA1C1F03R4Application/ADD/GGIO6.CBOpnd.stVal", point.ObjectReference); + Assert.Equal("Operated", point.ExpectedOnText); + Assert.Equal("Normal", point.ExpectedOffText); + Assert.Equal("Event Log", point.EvidenceExpected); + + Assert.Contains(result.ParserFindings, finding => finding.Code == "XLSX_NON_SDI_SKIPPED"); + Assert.Contains(result.ParserFindings, finding => finding.Code == "XLSX_BLOCKED_ROWS_SKIPPED"); + Assert.Contains(result.ParserFindings, finding => finding.Code == "DOCUMENT_CONTROL_REVIEW_REQUIRED"); + } + + private static string[] Rev3Headers() => + [ + "Point ID", "Include in FAT", "Test Mode", "Source Scope", "Location", + "Voltage Level", "Switchgear", "Panel Tag", "Object Name", "Equipment Description", + "PMS Typical", "Signal No.", "Signal Description", "IED Type", + "IED Identifier / Technical Key", "IP Address", "Signal Alias", "Interface", + "Data Type", "Alarm", "Event", "Trend", "Alarm Priority", "Alarm Severity", + "Engineering Unit", "From Equipment", "To Equipment", "State 0/01 Text (Source)", + "State 1/10 Text (Source)", "Dataset", "RCB/GCB", "CDC", "LD", "LN", "FC", + "IEC 61850 Reference (Source)", "Data Attribute", "Report Display Reference", + "Event Log Search Reference", "Unique Event Point Key", "Evidence Expected", + "Mapping Quality", "Review Status", "Review Reason", "Duplicate Key Count", + "Source Sheet", "Source Row", "FAT Result", "Actual / Observed", "Event Timestamp", + "Event Log Match", "Evidence Ref / Screenshot", "Reviewer Comment", "Source Remarks" + ]; + + private static string[] Row(string[] headers, params (string Header, string Value)[] values) + { + var lookup = values.ToDictionary(item => item.Header, item => item.Value, StringComparer.OrdinalIgnoreCase); + return headers.Select(header => lookup.TryGetValue(header, out var value) ? value : string.Empty).ToArray(); + } + + private static byte[] BuildWorkbook( + string[] pointHeaders, + IEnumerable pointRows, + IEnumerable documentControlRows) + { + using var memory = new MemoryStream(); + using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true)) + { + Write(archive, "xl/workbook.xml", + "" + + "" + + "" + + "" + + "" + + ""); + Write(archive, "xl/_rels/workbook.xml.rels", + "" + + "" + + "" + + "" + + ""); + + var allPointRows = new List { pointHeaders }; + allPointRows.AddRange(pointRows); + Write(archive, "xl/worksheets/sheet1.xml", BuildSheet(allPointRows)); + Write(archive, "xl/worksheets/sheet2.xml", BuildSheet(documentControlRows)); + } + return memory.ToArray(); + } + + private static string BuildSheet(IEnumerable rows) + { + var allRows = rows.ToList(); + var sheet = new StringBuilder(""); + for (var rowIndex = 0; rowIndex < allRows.Count; rowIndex++) + { + sheet.Append(""); + for (var columnIndex = 0; columnIndex < allRows[rowIndex].Length; columnIndex++) + { + var value = SecurityElement.Escape(allRows[rowIndex][columnIndex]) ?? string.Empty; + sheet.Append("").Append(value).Append(""); + } + sheet.Append(""); + } + sheet.Append(""); + return sheet.ToString(); + } + + private static void Write(ZipArchive archive, string path, string content) + { + var entry = archive.CreateEntry(path, CompressionLevel.Fastest); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, new UTF8Encoding(false)); + writer.Write(content); + } + + private static string ColumnName(int zeroBasedIndex) + { + var index = zeroBasedIndex + 1; + var name = string.Empty; + while (index > 0) + { + index--; + name = (char)('A' + index % 26) + name; + index /= 26; + } + return name; + } +} From 63f162f89711c21df39d0d6cea525642f6ba41af Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 15:07:34 +0700 Subject: [PATCH 22/56] Test executive FAT PDF traceability and signoff --- .../IoFatExecutiveReportLayoutTests.cs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/ARSAS.Tests/IoFatExecutiveReportLayoutTests.cs diff --git a/tests/ARSAS.Tests/IoFatExecutiveReportLayoutTests.cs b/tests/ARSAS.Tests/IoFatExecutiveReportLayoutTests.cs new file mode 100644 index 000000000..d82a24ccf --- /dev/null +++ b/tests/ARSAS.Tests/IoFatExecutiveReportLayoutTests.cs @@ -0,0 +1,105 @@ +using System.Text; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatExecutiveReportLayoutTests +{ + [Fact] + public void ExecutivePdf_ContainsDocumentControlExactTelegramAndHandoverSignoff() + { + var project = new IoTestProject + { + ProjectId = "UCC-2181801A-E99-0001", + SchemaVersion = IoTestImportValidator.SupportedSchemaVersion, + ProjectName = "Tangguh UCC Project - Onshore EPCI", + SourceWorkbookName = "IEC61850_FAT_Import_Rev3_EventLogReady.xlsx", + SourceWorkbookSha256 = new string('a', 64), + DocumentControl = new IoFatDocumentControl + { + ClientProject = "Tangguh UCC Project - Onshore EPCI", + SupplierName = "PT ABB Sakti Industri", + PurchaseOrderTitle = "Power Management System (PMS)", + PurchaserDocumentNumber = "V-2181-801-A-EIC-154", + CompanyProjectDocumentNumber = "UCC-2181801A-E99-0001", + Revision = "02", + IssueStatus = "AS TESTED" + }, + Ieds = + [ + new IoTestIedPlan + { + IedName = "AA1C1F03R4", + IpAddress = "192.168.81.70", + IedRole = "BCU - 6MD85", + Location = "ELECT-CCPP", + VoltageLevel = "66kV", + Switchgear = "261-SWG-51001", + TestPoints = + [ + new IoTestPointPlan + { + TestPointId = "UCC-IEC-0001", + IedName = "AA1C1F03R4", + IpAddress = "192.168.81.70", + SignalName = "CB opened", + ObjectReference = "AA1C1F03R4Application/ADD/GGIO6.CBOpnd.stVal", + FunctionalConstraint = "ST", + SourceIecReference = "ADD/GGIO6.CBOpnd", + EventLogSearchReference = "ADD/GGIO6.CBOpnd", + ExpectedOnText = "Operated", + ExpectedOffText = "Normal", + TestEnabled = true, + ImportReady = true + } + ] + } + ] + }; + + var bytes = IoFatPdfReportService.Generate( + project, + new DateTimeOffset(2026, 7, 30, 14, 0, 0, TimeSpan.FromHours(7))); + var text = Encoding.ASCII.GetString(bytes); + + Assert.StartsWith("%PDF-1.4", text, StringComparison.Ordinal); + Assert.Contains("V-2181-801-A-EIC-154", text, StringComparison.Ordinal); + Assert.Contains("REV 02", text, StringComparison.Ordinal); + Assert.Contains("ADD/GGIO6.CBOpnd", text, StringComparison.Ordinal); + Assert.Contains("EVENT-LOG CORRELATION", text, StringComparison.Ordinal); + Assert.Contains("CLIENT WITNESS", text, StringComparison.Ordinal); + Assert.Contains("IED timestamp format yyyy-MM-dd HH:mm:ss.fff", text, StringComparison.Ordinal); + } + + [Fact] + public void ExecutiveLayoutContract_IsCleanButAuditDefensible() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs")); + + Assert.Contains("IEC 61850 / event-log reference", source, StringComparison.Ordinal); + Assert.Contains("point.ReportIecReference", source, StringComparison.Ordinal); + Assert.Contains("DOCUMENT CONTROL", source, StringComparison.Ordinal); + Assert.Contains("FALSE > TRUE > FALSE", source, StringComparison.Ordinal); + Assert.Contains("TESTED BY", source, StringComparison.Ordinal); + Assert.Contains("CHECKED BY", source, StringComparison.Ordinal); + Assert.Contains("CLIENT WITNESS", source, StringComparison.Ordinal); + Assert.Contains("APPROVED BY", source, StringComparison.Ordinal); + Assert.Contains("yyyy-MM-dd\\nHH:mm:ss.fff", source, StringComparison.Ordinal); + Assert.DoesNotContain("CharacterEllipsis", source, StringComparison.Ordinal); + Assert.DoesNotContain(" + \"...\"", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 3df81324d6336aa58c895d3cd81099d4ae225c1e Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 16:10:54 +0700 Subject: [PATCH 23/56] Fix Rev3 IEC reference matching for IO FAT acquisition --- .../IoTesting/IoTestLiveBindingService.cs | 141 ++++++++++++++---- 1 file changed, 114 insertions(+), 27 deletions(-) diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs index 0bd79c058..7f419edb4 100644 --- a/Services/IoTesting/IoTestLiveBindingService.cs +++ b/Services/IoTesting/IoTestLiveBindingService.cs @@ -89,7 +89,7 @@ public IoTestLiveBindingSummary Bind( private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevice device) { - if (!point.ImportReady || string.IsNullOrWhiteSpace(point.ObjectReference)) + if (!point.ImportReady || ImportedReferences(point).Count == 0) { return new PointBinding( IoTestLiveBindingState.SignalNotFound, @@ -98,66 +98,143 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi null); } - var expected = NormalizeReference(point.ObjectReference); - var exactLivePoint = device.Points.FirstOrDefault(item => - NormalizeReference(item.IecReference).Equals(expected, StringComparison.OrdinalIgnoreCase)); - if (exactLivePoint != null) + var expectedReferences = ImportedReferences(point) + .Select(NormalizeReference) + .Where(value => value.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var exactLivePoints = device.Points + .Where(item => expectedReferences.Contains(NormalizeReference(item.IecReference))) + .ToList(); + if (exactLivePoints.Count == 1) { return new PointBinding( IoTestLiveBindingState.LivePointReady, - "Exact imported object reference is already active in the live monitor.", - exactLivePoint.IecReference, - exactLivePoint); + "Exact imported IEC 61850 reference is already active in the live monitor.", + exactLivePoints[0].IecReference, + exactLivePoints[0]); } - var exactSignal = device.Signals.FirstOrDefault(item => - !item.IsControlSignal && - NormalizeReference(item.ObjectReference).Equals(expected, StringComparison.OrdinalIgnoreCase)); - if (exactSignal != null) + var exactSignals = device.Signals + .Where(item => !item.IsControlSignal && + expectedReferences.Contains(NormalizeReference(item.ObjectReference))) + .ToList(); + if (exactSignals.Count == 1) { return new PointBinding( IoTestLiveBindingState.BoundExact, - "Exact imported object reference is present in the discovered IED model.", - exactSignal.ObjectReference, + "Exact imported IEC 61850 reference is present in the discovered IED model.", + exactSignals[0].ObjectReference, null); } - var expectedTelegram = NormalizeTelegram(point.ObjectReference, point.IedName); + var expectedTelegrams = ImportedReferences(point) + .Select(reference => NormalizeTelegram(reference, point.IedName)) + .Where(value => value.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var livePointCandidates = device.Points - .Where(item => NormalizeTelegram(item.IecReference, device.Name) - .Equals(expectedTelegram, StringComparison.OrdinalIgnoreCase)) + .Where(item => MatchesAnyTelegram(item.IecReference, device, expectedTelegrams)) .ToList(); if (livePointCandidates.Count == 1) { return new PointBinding( IoTestLiveBindingState.LivePointReady, - "Live point matched after normalizing the IED-name prefix.", + "Live point matched uniquely after normalizing the IED/Application wrapper.", livePointCandidates[0].IecReference, livePointCandidates[0]); } var signalCandidates = device.Signals .Where(item => !item.IsControlSignal && - NormalizeTelegram(item.ObjectReference, device.Name) - .Equals(expectedTelegram, StringComparison.OrdinalIgnoreCase)) + MatchesAnyTelegram(item.ObjectReference, device, expectedTelegrams)) .ToList(); if (signalCandidates.Count == 1) { return new PointBinding( IoTestLiveBindingState.BoundNormalized, - "Discovered signal matched after normalizing the IED-name prefix.", + "Discovered signal matched uniquely after normalizing the IED/Application wrapper.", signalCandidates[0].ObjectReference, null); } - var reason = signalCandidates.Count > 1 || livePointCandidates.Count > 1 + var reason = exactLivePoints.Count > 1 || exactSignals.Count > 1 || + signalCandidates.Count > 1 || livePointCandidates.Count > 1 ? "More than one live candidate matched the imported telegram; automatic binding was withheld." : device.Signals.Count == 0 ? "The IED is loaded but its signal model has not been discovered yet." - : "The imported object reference was not found in the loaded IED model."; + : "None of the imported IEC 61850/event-log references was found in the loaded IED model."; return new PointBinding(IoTestLiveBindingState.SignalNotFound, reason, string.Empty, null); } + internal static IReadOnlyList ImportedReferences(IoTestPointPlan point) + { + ArgumentNullException.ThrowIfNull(point); + var references = new List(); + + void Add(string? value) + { + var clean = RemoveFunctionalConstraintSuffix(value); + if (clean.Length > 0 && !references.Contains(clean, StringComparer.OrdinalIgnoreCase)) + references.Add(clean); + } + + Add(point.ObjectReference); + Add(point.EventLogSearchReference); + Add(point.SourceIecReference); + Add(point.ReportDisplayReference); + + var eventReference = !string.IsNullOrWhiteSpace(point.EventLogSearchReference) + ? point.EventLogSearchReference.Trim() + : point.SourceIecReference?.Trim() ?? string.Empty; + if (eventReference.Length > 0) + { + var bindable = eventReference; + if (!string.IsNullOrWhiteSpace(point.DataAttribute) && + !bindable.EndsWith("." + point.DataAttribute.Trim(), StringComparison.OrdinalIgnoreCase)) + { + bindable += "." + point.DataAttribute.Trim(); + } + + if (!bindable.Contains('/') && !string.IsNullOrWhiteSpace(point.LogicalDevice)) + bindable = point.LogicalDevice.Trim() + "/" + bindable.TrimStart('/'); + + Add(bindable); + if (!string.IsNullOrWhiteSpace(point.IedName) && + bindable.Contains('/') && + !bindable.StartsWith(point.IedName, StringComparison.OrdinalIgnoreCase)) + { + Add(point.IedName.Trim() + bindable); + } + } + + return references; + } + + private static string RemoveFunctionalConstraintSuffix(string? reference) + { + var value = (reference ?? string.Empty).Trim(); + var marker = value.LastIndexOf(" [", StringComparison.Ordinal); + if (marker > 0 && value.EndsWith(']')) + value = value[..marker].TrimEnd(); + return value; + } + + private static bool MatchesAnyTelegram( + string? observedReference, + Iec61850MonitorDevice device, + IReadOnlySet expectedTelegrams) + { + if (expectedTelegrams.Count == 0) + return false; + + if (expectedTelegrams.Contains(NormalizeTelegram(observedReference, device.Name))) + return true; + + return !string.IsNullOrWhiteSpace(device.SclIedName) && + expectedTelegrams.Contains(NormalizeTelegram(observedReference, device.SclIedName)); + } + private static Iec61850MonitorDevice? FindDevice( IoTestIedPlan plan, IReadOnlyCollection devices) @@ -191,16 +268,26 @@ internal static string NormalizeReference(string? reference) internal static string NormalizeTelegram(string? reference, string? iedName) { - var normalized = NormalizeReference(reference); + var normalized = NormalizeReference(RemoveFunctionalConstraintSuffix(reference)); var slash = normalized.IndexOf('/'); var name = (iedName ?? string.Empty).Trim().ToLowerInvariant(); if (slash <= 0 || string.IsNullOrWhiteSpace(name)) return normalized; var domain = normalized[..slash]; - if (domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) && domain.Length > name.Length) - return domain[name.Length..] + normalized[slash..]; - return normalized; + if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase)) + return normalized; + + var domainSuffix = domain[name.Length..]; + var path = normalized[(slash + 1)..].TrimStart('/'); + + // Rev.3 report traceability may use IEDNameApplication/LD/LN.DO.DA, + // while the discovered MMS model uses IEDNameLD/LN.DO.DA. Both identify + // the same telegram; "Application" is a display wrapper, not an LD name. + if (domainSuffix.Equals("application", StringComparison.OrdinalIgnoreCase)) + return path; + + return domainSuffix.Length == 0 ? path : domainSuffix + "/" + path; } private sealed record PointBinding( From fbf58d018ec4a5f9eebdd79ea7bf2ba6467c6ce1 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 16:11:30 +0700 Subject: [PATCH 24/56] Match Rev3 FAT points by exact event-log references --- .../IoTesting/IoTestSignalSelectionService.cs | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/Services/IoTesting/IoTestSignalSelectionService.cs b/Services/IoTesting/IoTestSignalSelectionService.cs index 1a98e67e2..774d225cd 100644 --- a/Services/IoTesting/IoTestSignalSelectionService.cs +++ b/Services/IoTesting/IoTestSignalSelectionService.cs @@ -20,8 +20,9 @@ public sealed record IoTestSignalSelectionResult( /// /// Resolves the enabled IO-list scope against one discovered IED model without -/// guessing. Exact object references are preferred. A normalized IED-name prefix -/// is accepted only when it produces one unique non-control signal. +/// guessing. Exact source/event-log references are preferred. A normalized +/// IED-name or Application wrapper is accepted only when it produces one unique +/// non-control signal. /// public sealed class IoTestSignalSelectionService { @@ -42,12 +43,15 @@ public IoTestSignalSelectionResult Resolve( foreach (var point in requested) { + var exactReferences = IoTestLiveBindingService.ImportedReferences(point) + .Select(IoTestLiveBindingService.NormalizeReference) + .Where(value => value.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var candidates = device.Signals .Where(signal => IsEligible(signal, point)) - .Where(signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference) - .Equals( - IoTestLiveBindingService.NormalizeReference(point.ObjectReference), - StringComparison.OrdinalIgnoreCase)) + .Where(signal => exactReferences.Contains( + IoTestLiveBindingService.NormalizeReference(signal.ObjectReference))) .ToList(); var usedNormalizedPrefix = false; @@ -112,23 +116,29 @@ private static bool NormalizedTelegramMatches( IoTestIedPlan ied, Iec61850MonitorDevice device) { - var expected = IoTestLiveBindingService.NormalizeTelegram(point.ObjectReference, ied.IedName); + var expected = IoTestLiveBindingService.ImportedReferences(point) + .Select(reference => IoTestLiveBindingService.NormalizeTelegram(reference, ied.IedName)) + .Where(value => value.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (expected.Count == 0) + return false; + var observed = IoTestLiveBindingService.NormalizeTelegram(signal.ObjectReference, device.Name); - if (observed.Equals(expected, StringComparison.OrdinalIgnoreCase)) + if (expected.Contains(observed)) return true; if (string.IsNullOrWhiteSpace(device.SclIedName)) return false; observed = IoTestLiveBindingService.NormalizeTelegram(signal.ObjectReference, device.SclIedName); - return observed.Equals(expected, StringComparison.OrdinalIgnoreCase); + return expected.Contains(observed); } private static string Describe(IReadOnlyCollection points) { var values = points .Take(4) - .Select(point => $"{point.TestPointId} ({point.ObjectReference})") + .Select(point => $"{point.TestPointId} ({point.ReportIecReference})") .ToList(); if (points.Count > values.Count) values.Add($"…and {points.Count - values.Count} more"); From 387b76272001330417045a6617cf21f2ccdc56e4 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 16:12:50 +0700 Subject: [PATCH 25/56] Cover Rev3 Application and event-log reference matching --- .../IoTestSignalSelectionServiceTests.cs | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/IoTestSignalSelectionServiceTests.cs b/tests/ARSAS.Tests/IoTestSignalSelectionServiceTests.cs index 0d953a15b..20a823032 100644 --- a/tests/ARSAS.Tests/IoTestSignalSelectionServiceTests.cs +++ b/tests/ARSAS.Tests/IoTestSignalSelectionServiceTests.cs @@ -37,6 +37,48 @@ public void UniqueIedPrefixDifference_IsAccepted() Assert.True(Assert.Single(result.Matches).UsedNormalizedIedPrefix); } + [Fact] + public void Rev3ApplicationDisplayWrapper_MatchesDiscoveredMmsReference() + { + var ied = Ied(Point( + "UCC-IEC-0769", + "AA1C1F00R1Application/ADD/GGIO2.LockoutOp.stVal")); + var device = Device(Signal( + "Lockout operated", + "AA1C1F00R1ADD/GGIO2.LockoutOp.stVal")); + + var result = _service.Resolve(ied, device); + + Assert.True(result.Succeeded); + var match = Assert.Single(result.Matches); + Assert.Equal("Lockout operated", match.Signal.Name); + Assert.True(match.UsedNormalizedIedPrefix); + Assert.Equal( + "add/ggio2.lockoutop.stval", + IoTestLiveBindingService.NormalizeTelegram( + "AA1C1F00R1Application/ADD/GGIO2.LockoutOp.stVal", + "AA1C1F00R1")); + } + + [Fact] + public void ExactEventLogReference_IsUsedWhenDisplayReferenceDiffers() + { + var ied = Ied(Point( + "UCC-IEC-0773", + "AA1C1F00R1Application/CB1/XCBR1.TripOpnCmd.stVal", + eventLogReference: "CB1/XCBR1.TripOpnCmd", + sourceIecReference: "CB1/XCBR1.TripOpnCmd", + dataAttribute: "stVal")); + var device = Device(Signal( + "Protection operated", + "AA1C1F00R1CB1/XCBR1.TripOpnCmd.stVal")); + + var result = _service.Resolve(ied, device); + + Assert.True(result.Succeeded); + Assert.Single(result.Matches); + } + [Fact] public void MissingSignal_RequestsOneFreshDiscoveryRetry() { @@ -102,7 +144,14 @@ public void KnownFunctionalConstraintMismatch_IsNotAccepted() TestPoints = points.ToList() }; - private static IoTestPointPlan Point(string id, string reference, string functionalConstraint = "ST") => new() + private static IoTestPointPlan Point( + string id, + string reference, + string functionalConstraint = "ST", + string eventLogReference = "", + string sourceIecReference = "", + string dataAttribute = "", + string logicalDevice = "") => new() { TestPointId = id, IedName = "AA1C1F00R1", @@ -110,6 +159,10 @@ public void KnownFunctionalConstraintMismatch_IsNotAccepted() SignalName = id, ObjectReference = reference, FunctionalConstraint = functionalConstraint, + EventLogSearchReference = eventLogReference, + SourceIecReference = sourceIecReference, + DataAttribute = dataAttribute, + LogicalDevice = logicalDevice, ExpectedOnText = "Active", ExpectedOffText = "InActive", ImportReady = true, From 769b8584abf9406287c51ffa9095b662dc108612 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 16:15:26 +0700 Subject: [PATCH 26/56] Test Rev3 reference matching through public selection behavior --- tests/ARSAS.Tests/IoTestSignalSelectionServiceTests.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/ARSAS.Tests/IoTestSignalSelectionServiceTests.cs b/tests/ARSAS.Tests/IoTestSignalSelectionServiceTests.cs index 20a823032..4d17a428e 100644 --- a/tests/ARSAS.Tests/IoTestSignalSelectionServiceTests.cs +++ b/tests/ARSAS.Tests/IoTestSignalSelectionServiceTests.cs @@ -53,11 +53,6 @@ public void Rev3ApplicationDisplayWrapper_MatchesDiscoveredMmsReference() var match = Assert.Single(result.Matches); Assert.Equal("Lockout operated", match.Signal.Name); Assert.True(match.UsedNormalizedIedPrefix); - Assert.Equal( - "add/ggio2.lockoutop.stval", - IoTestLiveBindingService.NormalizeTelegram( - "AA1C1F00R1Application/ADD/GGIO2.LockoutOp.stVal", - "AA1C1F00R1")); } [Fact] From d6032ec18a110508d045b64aa3361f1c1b363a7c Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 17:05:14 +0700 Subject: [PATCH 27/56] Polish FAT evidence report hierarchy and status presentation --- .../IoFatExecutiveReportLayoutEngine.cs | 73 ++++++++++--------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs b/Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs index fac571d5e..e61d17dea 100644 --- a/Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs +++ b/Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs @@ -25,6 +25,8 @@ internal static class IoFatExecutiveReportLayoutEngine private static readonly IoFatReportColor Navy = Color("0F172A"); private static readonly IoFatReportColor Blue = Color("2563EB"); private static readonly IoFatReportColor SoftBlue = Color("EFF6FF"); + private static readonly IoFatReportColor DocumentFill = Color("F8FAFC"); + private static readonly IoFatReportColor DocumentBorder = Color("CBD5E1"); private static readonly IoFatReportColor Border = Color("D9E4F0"); private static readonly IoFatReportColor SoftLine = Color("EDF2F7"); private static readonly IoFatReportColor Muted = Color("64748B"); @@ -33,9 +35,7 @@ internal static class IoFatExecutiveReportLayoutEngine private static readonly IoFatReportColor Pass = Color("15803D"); private static readonly IoFatReportColor Attention = Color("B45309"); private static readonly IoFatReportColor Fail = Color("B91C1C"); - private static readonly IoFatReportColor SoftPass = Color("F0FDF4"); private static readonly IoFatReportColor SoftAttention = Color("FFFBEB"); - private static readonly IoFatReportColor SoftFail = Color("FEF2F2"); public static IoFatReportLayoutPlan Build(IoTestProject project, DateTimeOffset created, bool draft = false) { @@ -105,29 +105,29 @@ private void DrawPageChrome(PageBuilder page, int pageNumber, int totalPages) control.CompanyProjectDocumentNumber, _project.ProjectId); var revision = FirstNonEmpty(control.Revision, "-"); - var issueStatus = FirstNonEmpty(control.IssueStatus, _draft ? "DRAFT / LIVE" : "AS TESTED"); + // A generated evidence attachment is an as-tested record. Source-workbook + // review flags remain in project diagnostics but are not customer issue status. + var issueStatus = _draft ? "PREVIEW" : "AS TESTED"; var supplier = FirstNonEmpty(control.SupplierName, "Supplier not stated"); var poTitle = FirstNonEmpty(control.PurchaseOrderTitle, control.DocumentTitle); page.Line(Margin, HeaderBottom, PageWidth - Margin, HeaderBottom, Border, 0.8d); page.Text(Margin, 566d, 480d, Clean(projectName), IoFatReportFontKind.Bold, 7.2d, Muted); - page.Text(Margin, 542d, 500d, "IEC 61850 FAT Evidence Report", IoFatReportFontKind.Bold, 20.2d, Navy); - page.Text(Margin, 522d, 500d, "Signal-state verification with IED timestamps and event-log traceability.", IoFatReportFontKind.Regular, 8.1d, Muted); - page.Text(Margin, 505d, 520d, BuildSupplierLine(supplier, poTitle), IoFatReportFontKind.Regular, 6.6d, Muted); + page.Text(Margin, 544d, 500d, "IEC 61850 FAT Evidence Report", IoFatReportFontKind.Bold, 16.8d, Navy); + page.Text(Margin, 524d, 500d, "Signal-state verification with IED timestamps and event-log traceability.", IoFatReportFontKind.Regular, 8.0d, Muted); + page.Text(Margin, 507d, 520d, BuildSupplierLine(supplier, poTitle), IoFatReportFontKind.Regular, 6.6d, Muted); const double cardWidth = 222d; - const double cardHeight = 67d; + const double cardHeight = 64d; var cardX = PageWidth - Margin - cardWidth; - const double cardTop = 569d; - var reviewIssue = issueStatus.Contains("REVIEW", StringComparison.OrdinalIgnoreCase) || _draft; - var statusColor = reviewIssue ? Attention : Blue; - var statusBackground = reviewIssue ? SoftAttention : SoftBlue; + const double cardTop = 568d; - page.RoundRect(cardX, cardTop, cardWidth, cardHeight, 6d, statusBackground, statusColor, 0.8d); + // Document control is neutral metadata, not an alarm/status card. + page.RoundRect(cardX, cardTop, cardWidth, cardHeight, 4d, DocumentFill, DocumentBorder, 0.7d); page.Text(cardX + 11d, cardTop - 14d, cardWidth - 22d, "DOCUMENT CONTROL", IoFatReportFontKind.Bold, 5.9d, Muted); page.Text(cardX + 11d, cardTop - 30d, cardWidth - 22d, Clean(documentNumber), IoFatReportFontKind.Bold, 8.2d, Navy); - page.Text(cardX + 11d, cardTop - 45d, cardWidth - 22d, $"REV {Clean(revision)} | {Clean(issueStatus)}", IoFatReportFontKind.Bold, 6.7d, statusColor); - page.Text(cardX + 11d, cardTop - 58d, cardWidth - 22d, _draft ? "PREVIEW - NOT FOR ISSUE" : "CUSTOMER FAT RECORD", IoFatReportFontKind.Regular, 5.8d, Muted); + page.Text(cardX + 11d, cardTop - 45d, cardWidth - 22d, $"REV {Clean(revision)} | {issueStatus}", IoFatReportFontKind.Bold, 6.7d, Navy); + page.Text(cardX + 11d, cardTop - 57d, cardWidth - 22d, _draft ? "NOT FOR ISSUE" : "CUSTOMER FAT RECORD", IoFatReportFontKind.Regular, 5.8d, Muted); page.Line(Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); page.Text(Margin, 24d, 540d, @@ -139,7 +139,7 @@ private void DrawPageChrome(PageBuilder page, int pageNumber, int totalPages) private void DrawIedSection(IoTestIedPlan ied) { var points = ReportPoints(ied); - Ensure(82d); + Ensure(76d); DrawIedHeader(ied, points, continued: false); DrawTableHeader(); @@ -179,19 +179,14 @@ private void DrawIedHeader(IoTestIedPlan ied, IReadOnlyList poi var pending = Math.Max(0, points.Count - passed - review - failed); var status = $"{passed} OF {points.Count} SIGNALS PASSED"; var statusColor = failed > 0 ? Fail : review > 0 || pending > 0 ? Attention : Pass; - var statusBackground = failed > 0 ? SoftFail : review > 0 || pending > 0 ? SoftAttention : SoftPass; - const double height = 50d; - - _page.RoundRect(Margin, _cursorY, ContentWidth, height, 6d, White, Border, 0.7d); - _page.Rect(Margin, _cursorY, 4d, height, statusColor, statusColor, 0d); - _page.Text(Margin + 14d, _cursorY - 18d, 390d, Clean(title), IoFatReportFontKind.Bold, 10.7d, Navy); - _page.Text(Margin + 14d, _cursorY - 35d, 500d, BuildDeviceMeta(ied), IoFatReportFontKind.Regular, 6.9d, Muted); - - const double badgeWidth = 188d; - var badgeX = PageWidth - Margin - badgeWidth - 10d; - _page.RoundRect(badgeX, _cursorY - 9d, badgeWidth, 30d, 5d, statusBackground, statusColor, 0.7d); - _page.Text(badgeX + 10d, _cursorY - 21d, badgeWidth - 20d, "DEVICE RESULT", IoFatReportFontKind.Bold, 5.6d, Muted); - _page.Text(badgeX + 10d, _cursorY - 35d, badgeWidth - 20d, status, IoFatReportFontKind.Bold, 8.1d, statusColor); + const double height = 43d; + + // A report section should read like a controlled document, not an application card. + _page.Rect(Margin, _cursorY, 4d, height - 3d, statusColor, statusColor, 0d); + _page.Text(Margin + 14d, _cursorY - 15d, 390d, Clean(title), IoFatReportFontKind.Bold, 10.5d, Navy); + _page.Text(Margin + 14d, _cursorY - 32d, 500d, BuildDeviceMeta(ied), IoFatReportFontKind.Regular, 6.9d, Muted); + _page.Text(PageWidth - Margin - 190d, _cursorY - 32d, 180d, status, IoFatReportFontKind.Bold, 7.1d, statusColor); + _page.Line(Margin + 4d, _cursorY - height, PageWidth - Margin, _cursorY - height, Border, 0.65d); _cursorY -= height + 7d; } @@ -365,12 +360,18 @@ private static ProjectCounts Counts(IoTestProject project) private static string ExpectedStateText(IoTestPointPlan point) { - var trueLabel = NormalizeExpectedLabel(point.ExpectedOnText, "ON", "True"); - var falseLabel = NormalizeExpectedLabel(point.ExpectedOffText, "OFF", "False"); - return $"{trueLabel} (True)\n{falseLabel} (False)"; + var trueLabel = NormalizeExpectedLabel(point.ExpectedOnText, "ON"); + var falseLabel = NormalizeExpectedLabel(point.ExpectedOffText, "OFF"); + return $"{ExpectedLine(trueLabel, true)}\n{ExpectedLine(falseLabel, false)}"; } - private static string NormalizeExpectedLabel(string? value, string prefix, string booleanText) + private static string ExpectedLine(string label, bool value) + { + var booleanText = value ? "True" : "False"; + return string.IsNullOrWhiteSpace(label) ? booleanText.ToUpperInvariant() : $"{label} ({booleanText})"; + } + + private static string NormalizeExpectedLabel(string? value, string prefix) { var clean = Clean(value); foreach (var suffix in new[] { " (1)", " (0)", " (True)", " (False)" }) @@ -382,6 +383,12 @@ private static string NormalizeExpectedLabel(string? value, string prefix, strin } } + if (clean.Equals("TBA", StringComparison.OrdinalIgnoreCase) || + clean.Equals("NA", StringComparison.OrdinalIgnoreCase) || + clean.Equals("N/A", StringComparison.OrdinalIgnoreCase) || + clean == "-") + return string.Empty; + if (clean.Equals("1", StringComparison.OrdinalIgnoreCase) || clean.Equals("true", StringComparison.OrdinalIgnoreCase)) return "ON"; if (clean.Equals("0", StringComparison.OrdinalIgnoreCase) || clean.Equals("false", StringComparison.OrdinalIgnoreCase)) @@ -390,7 +397,7 @@ private static string NormalizeExpectedLabel(string? value, string prefix, strin var repeatedPrefix = prefix + " "; if (clean.StartsWith(repeatedPrefix, StringComparison.OrdinalIgnoreCase)) clean = clean[repeatedPrefix.Length..].Trim(); - return string.IsNullOrWhiteSpace(clean) || clean == "-" ? booleanText : clean; + return clean; } private static string RelayTime(IoTestTransitionEvidence? evidence) From b875f3583425cb3f563a50f0aae5cac5537826aa Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 17:05:56 +0700 Subject: [PATCH 28/56] Use modern report font fallback stack --- Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs index d0e43741a..e62511b34 100644 --- a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs +++ b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs @@ -18,7 +18,7 @@ namespace ArIED61850Tester.Services.IoTesting; internal static class IoFatReportPreviewDocumentBuilder { private const double DipPerPdfPoint = 96d / 72d; - private static readonly FontFamily ReportFont = new("Arial, Segoe UI"); + private static readonly FontFamily ReportFont = new("Inter, Segoe UI, Aptos, Arial"); private static readonly FontFamily MonoFont = new("Consolas, Cascadia Mono"); public static FixedDocument Build( @@ -134,6 +134,9 @@ private static void AddText(FixedPage page, double pageHeight, IoFatReportTextCo block.SetValue(TextOptions.TextFormattingModeProperty, TextFormattingMode.Ideal); block.SetValue(TextOptions.TextRenderingModeProperty, TextRenderingMode.ClearType); + // Downscale only when the actual Windows font metrics are wider than the + // shared PDF-point estimate. This keeps PASS and timestamps complete while + // preserving the intended size whenever they already fit. var textPresenter = new Viewbox { Child = block, From 18573e928c8e1da8b3edb5cdccf6ebecf5e61a86 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 30 Jul 2026 17:06:36 +0700 Subject: [PATCH 29/56] Lock final FAT report visual hierarchy and as-tested status --- .../IoFatExecutiveReportLayoutTests.cs | 120 +++++++++++------- 1 file changed, 72 insertions(+), 48 deletions(-) diff --git a/tests/ARSAS.Tests/IoFatExecutiveReportLayoutTests.cs b/tests/ARSAS.Tests/IoFatExecutiveReportLayoutTests.cs index d82a24ccf..89d168312 100644 --- a/tests/ARSAS.Tests/IoFatExecutiveReportLayoutTests.cs +++ b/tests/ARSAS.Tests/IoFatExecutiveReportLayoutTests.cs @@ -9,54 +9,7 @@ public sealed class IoFatExecutiveReportLayoutTests [Fact] public void ExecutivePdf_ContainsDocumentControlExactTelegramAndHandoverSignoff() { - var project = new IoTestProject - { - ProjectId = "UCC-2181801A-E99-0001", - SchemaVersion = IoTestImportValidator.SupportedSchemaVersion, - ProjectName = "Tangguh UCC Project - Onshore EPCI", - SourceWorkbookName = "IEC61850_FAT_Import_Rev3_EventLogReady.xlsx", - SourceWorkbookSha256 = new string('a', 64), - DocumentControl = new IoFatDocumentControl - { - ClientProject = "Tangguh UCC Project - Onshore EPCI", - SupplierName = "PT ABB Sakti Industri", - PurchaseOrderTitle = "Power Management System (PMS)", - PurchaserDocumentNumber = "V-2181-801-A-EIC-154", - CompanyProjectDocumentNumber = "UCC-2181801A-E99-0001", - Revision = "02", - IssueStatus = "AS TESTED" - }, - Ieds = - [ - new IoTestIedPlan - { - IedName = "AA1C1F03R4", - IpAddress = "192.168.81.70", - IedRole = "BCU - 6MD85", - Location = "ELECT-CCPP", - VoltageLevel = "66kV", - Switchgear = "261-SWG-51001", - TestPoints = - [ - new IoTestPointPlan - { - TestPointId = "UCC-IEC-0001", - IedName = "AA1C1F03R4", - IpAddress = "192.168.81.70", - SignalName = "CB opened", - ObjectReference = "AA1C1F03R4Application/ADD/GGIO6.CBOpnd.stVal", - FunctionalConstraint = "ST", - SourceIecReference = "ADD/GGIO6.CBOpnd", - EventLogSearchReference = "ADD/GGIO6.CBOpnd", - ExpectedOnText = "Operated", - ExpectedOffText = "Normal", - TestEnabled = true, - ImportReady = true - } - ] - } - ] - }; + var project = BuildProject("REVIEW REQUIRED", "Operated", "Normal"); var bytes = IoFatPdfReportService.Generate( project, @@ -66,16 +19,33 @@ public void ExecutivePdf_ContainsDocumentControlExactTelegramAndHandoverSignoff( Assert.StartsWith("%PDF-1.4", text, StringComparison.Ordinal); Assert.Contains("V-2181-801-A-EIC-154", text, StringComparison.Ordinal); Assert.Contains("REV 02", text, StringComparison.Ordinal); + Assert.Contains("AS TESTED", text, StringComparison.Ordinal); + Assert.DoesNotContain("REVIEW REQUIRED", text, StringComparison.Ordinal); Assert.Contains("ADD/GGIO6.CBOpnd", text, StringComparison.Ordinal); Assert.Contains("EVENT-LOG CORRELATION", text, StringComparison.Ordinal); Assert.Contains("CLIENT WITNESS", text, StringComparison.Ordinal); Assert.Contains("IED timestamp format yyyy-MM-dd HH:mm:ss.fff", text, StringComparison.Ordinal); } + [Fact] + public void ExecutivePdf_ReplacesUnknownSourceStateLabelsWithBooleanStates() + { + var project = BuildProject("REVIEW REQUIRED", "TBA", "N/A"); + + var bytes = IoFatPdfReportService.Generate(project); + var text = Encoding.ASCII.GetString(bytes); + + Assert.Contains("TRUE", text, StringComparison.Ordinal); + Assert.Contains("FALSE", text, StringComparison.Ordinal); + Assert.DoesNotContain("TBA (True)", text, StringComparison.Ordinal); + Assert.DoesNotContain("N/A (False)", text, StringComparison.Ordinal); + } + [Fact] public void ExecutiveLayoutContract_IsCleanButAuditDefensible() { var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatExecutiveReportLayoutEngine.cs")); + var preview = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs")); Assert.Contains("IEC 61850 / event-log reference", source, StringComparison.Ordinal); Assert.Contains("point.ReportIecReference", source, StringComparison.Ordinal); @@ -86,10 +56,64 @@ public void ExecutiveLayoutContract_IsCleanButAuditDefensible() Assert.Contains("CLIENT WITNESS", source, StringComparison.Ordinal); Assert.Contains("APPROVED BY", source, StringComparison.Ordinal); Assert.Contains("yyyy-MM-dd\\nHH:mm:ss.fff", source, StringComparison.Ordinal); + Assert.Contains("IoFatReportFontKind.Bold, 16.8d", source, StringComparison.Ordinal); + Assert.Contains("var issueStatus = _draft ? \"PREVIEW\" : \"AS TESTED\"", source, StringComparison.Ordinal); + Assert.Contains("Inter, Segoe UI, Aptos, Arial", preview, StringComparison.Ordinal); + Assert.DoesNotContain("DEVICE RESULT", source, StringComparison.Ordinal); + Assert.DoesNotContain("reviewIssue", source, StringComparison.Ordinal); Assert.DoesNotContain("CharacterEllipsis", source, StringComparison.Ordinal); Assert.DoesNotContain(" + \"...\"", source, StringComparison.Ordinal); } + private static IoTestProject BuildProject(string issueStatus, string expectedOn, string expectedOff) => new() + { + ProjectId = "UCC-2181801A-E99-0001", + SchemaVersion = IoTestImportValidator.SupportedSchemaVersion, + ProjectName = "Tangguh UCC Project - Onshore EPCI", + SourceWorkbookName = "IEC61850_FAT_Import_Rev3_EventLogReady.xlsx", + SourceWorkbookSha256 = new string('a', 64), + DocumentControl = new IoFatDocumentControl + { + ClientProject = "Tangguh UCC Project - Onshore EPCI", + SupplierName = "PT ABB Sakti Industri", + PurchaseOrderTitle = "Power Management System (PMS)", + PurchaserDocumentNumber = "V-2181-801-A-EIC-154", + CompanyProjectDocumentNumber = "UCC-2181801A-E99-0001", + Revision = "02", + IssueStatus = issueStatus + }, + Ieds = + [ + new IoTestIedPlan + { + IedName = "AA1C1F03R4", + IpAddress = "192.168.81.70", + IedRole = "BCU - 6MD85", + Location = "ELECT-CCPP", + VoltageLevel = "66kV", + Switchgear = "261-SWG-51001", + TestPoints = + [ + new IoTestPointPlan + { + TestPointId = "UCC-IEC-0001", + IedName = "AA1C1F03R4", + IpAddress = "192.168.81.70", + SignalName = "CB opened", + ObjectReference = "AA1C1F03R4Application/ADD/GGIO6.CBOpnd.stVal", + FunctionalConstraint = "ST", + SourceIecReference = "ADD/GGIO6.CBOpnd", + EventLogSearchReference = "ADD/GGIO6.CBOpnd", + ExpectedOnText = expectedOn, + ExpectedOffText = expectedOff, + TestEnabled = true, + ImportReady = true + } + ] + } + ] + }; + private static string FindRepoFile(string relativePath) { DirectoryInfo? directory = new(AppContext.BaseDirectory); From c9c22d2d14e7dba4041f0eae2de75ae616062d45 Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 31 Jul 2026 14:26:04 +0700 Subject: [PATCH 30/56] Add persistent workspace mode switch --- MainWindow.WorkspaceModeSwitch.cs | 120 ++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 MainWindow.WorkspaceModeSwitch.cs diff --git a/MainWindow.WorkspaceModeSwitch.cs b/MainWindow.WorkspaceModeSwitch.cs new file mode 100644 index 000000000..fd3fc21f0 --- /dev/null +++ b/MainWindow.WorkspaceModeSwitch.cs @@ -0,0 +1,120 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private const string WorkspaceModeSwitchTag = "ARSAS_WORKSPACE_MODE_SWITCH"; + private static readonly bool WorkspaceModeSwitchRegistered = RegisterWorkspaceModeSwitch(); + + private static bool RegisterWorkspaceModeSwitch() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(WorkspaceModeSwitch_Loaded)); + return true; + } + + private static void WorkspaceModeSwitch_Loaded(object sender, RoutedEventArgs e) + { + if (sender is MainWindow window) + window.InstallWorkspaceModeSwitch(); + } + + private void InstallWorkspaceModeSwitch() + { + if (Content is not Grid root) + return; + + var header = root.Children.OfType().FirstOrDefault(child => Grid.GetRow(child) == 0); + if (header == null || header.Children.OfType() + .Any(child => Equals(child.Tag, WorkspaceModeSwitchTag))) + return; + + var shell = new Border + { + Tag = WorkspaceModeSwitchTag, + Background = WorkspaceBrush("#E7ECF5"), + BorderBrush = WorkspaceBrush("#D5DEEB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(4), + Margin = new Thickness(10, 0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + ToolTip = "Switch between Engineering and IO List FAT workspaces" + }; + Grid.SetColumn(shell, 1); + + var modes = new StackPanel { Orientation = Orientation.Horizontal }; + modes.Children.Add(new Border + { + Background = TryFindResource("Accent") as Brush ?? WorkspaceBrush("#2563EB"), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(12, 7), + Child = new TextBlock + { + Text = "ENGINEERING", + Foreground = Brushes.White, + FontSize = 10.5, + FontWeight = FontWeights.Bold, + VerticalAlignment = VerticalAlignment.Center + } + }); + + var fatButton = new Button + { + Content = "IO LIST FAT", + Style = TryFindResource("SoftButton") as Style, + Padding = new Thickness(12, 7), + Margin = new Thickness(4, 0, 0, 0), + FontSize = 10.5, + FontWeight = FontWeights.Bold, + Cursor = Cursors.Hand, + ToolTip = "Open an IO List FAT workspace" + }; + fatButton.Click += OpenIoFatWorkspaceMenu_Click; + modes.Children.Add(fatButton); + + shell.Child = modes; + header.Children.Add(shell); + } + + private void OpenIoFatWorkspaceMenu_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button anchor) + return; + + var menu = new ContextMenu + { + PlacementTarget = anchor, + Placement = PlacementMode.Bottom, + VerticalOffset = 5, + StaysOpen = false + }; + + var importWorkbook = new MenuItem { Header = "Import IO List Excel workbook" }; + importWorkbook.Click += OpenIoListTesting_Click; + var openProject = new MenuItem { Header = "Open portable .arsas project" }; + openProject.Click += OpenIoListPackage_Click; + + menu.Items.Add(importWorkbook); + menu.Items.Add(openProject); + menu.IsOpen = true; + } + + private static SolidColorBrush WorkspaceBrush(string hex) + { + var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); + brush.Freeze(); + return brush; + } +} From cc0538ccf67276aee1f87b8255244cb87975aded Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 31 Jul 2026 14:26:26 +0700 Subject: [PATCH 31/56] Add explicit IO FAT and Engineering mode selector --- IoListTestingWindow.WorkspaceModeSwitch.cs | 83 ++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 IoListTestingWindow.WorkspaceModeSwitch.cs diff --git a/IoListTestingWindow.WorkspaceModeSwitch.cs b/IoListTestingWindow.WorkspaceModeSwitch.cs new file mode 100644 index 000000000..f31958200 --- /dev/null +++ b/IoListTestingWindow.WorkspaceModeSwitch.cs @@ -0,0 +1,83 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private const string FatWorkspaceModeTag = "ARSAS_FAT_WORKSPACE_MODE"; + private static readonly bool FatWorkspaceModeRegistered = RegisterFatWorkspaceMode(); + + private static bool RegisterFatWorkspaceMode() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(FatWorkspaceMode_Loaded)); + return true; + } + + private static void FatWorkspaceMode_Loaded(object sender, RoutedEventArgs e) + { + if (sender is IoListTestingWindow window) + window.InstallFatWorkspaceModeSwitch(); + } + + private void InstallFatWorkspaceModeSwitch() + { + var engineeringButton = VisualDescendants