/*************************************************************************************************** Copyright (C) 2025 The Qt Company Ltd. SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only ***************************************************************************************************/ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using DiffPlex; using DiffPlex.DiffBuilder; using DiffPlex.DiffBuilder.Model; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Test_Qt.DotNet.Generator.Support { internal enum DiffFormat { Unified /* Context, SideBySide */ } internal static class DiffAssert { /// Optional sanitizer (e.g., strip timestamps/paths). internal static Func Sanitize { get; set; } = s => s; /// /// Compare two strings in memory. /// internal static void ContentEquals(string actual, string expected, int contextLines = 3, DiffFormat format = DiffFormat.Unified) { var actualContent = Sanitize(NormalizeNewlines(actual)); var expectedContent = Sanitize(NormalizeNewlines(expected)); if (!string.Equals(expectedContent, actualContent, StringComparison.Ordinal)) FailWithDiff(expectedContent, actualContent, contextLines, format); } /// /// Compare a string in memory against a file on disk. /// internal static void ContentEquals(string actual, FileInfo expected, int contextLines = 3, DiffFormat format = DiffFormat.Unified) { if (expected is not { Exists: true }) Assert.Fail($"Expected file not found: {expected?.FullName ?? ""}"); var actualContent = Sanitize(NormalizeNewlines(actual)); var expectedContent = Sanitize(NormalizeNewlines(File.ReadAllText(expected.FullName, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)))); if (!string.Equals(expectedContent, actualContent, StringComparison.Ordinal)) FailWithDiff(expectedContent, actualContent, contextLines, format); } private static string NormalizeNewlines(string text) { return string.IsNullOrEmpty(text) ? "" : text.Replace("\r\n", "\n").Replace('\r', '\n'); } private static void FailWithDiff(string expected, string actual, int contextLines, DiffFormat format) { var diff = format switch { DiffFormat.Unified => BuildUnifiedDiff(expected, actual, contextLines), _ => throw new NotSupportedException($"Diff format '{format}' not implemented.") }; Assert.Fail("Content mismatch:\n\n" + diff); } private static string BuildUnifiedDiff(string expected, string actual, int contextLines) { var differ = new Differ(); var diffModel = new SideBySideDiffBuilder(differ).BuildDiffModel(expected, actual); var expectedLines = diffModel.OldText.Lines; var actualLines = diffModel.NewText.Lines; var maxLineCount = Math.Max(expectedLines.Count, actualLines.Count); var changedLineIndices = Enumerable.Range(0, maxLineCount) .Where(i => { var expectedLineType = i < expectedLines.Count ? expectedLines[i].Type : ChangeType.Imaginary; var actualLineType = i < actualLines.Count ? actualLines[i].Type : ChangeType.Imaginary; return expectedLineType != ChangeType.Unchanged || actualLineType != ChangeType.Unchanged; }) .ToArray(); var diffBuilder = new StringBuilder(); diffBuilder.AppendLine("--- a/expected"); diffBuilder.AppendLine("+++ b/actual"); if (changedLineIndices.Length == 0) return diffBuilder.ToString(); for (var changedIndex = 0; changedIndex < changedLineIndices.Length;) { var hunkStart = Math.Max(changedLineIndices[changedIndex] - contextLines, 0); var hunkEnd = Math.Min(changedLineIndices[changedIndex] + contextLines, maxLineCount - 1); // Merge adjacent hunks within context var nextChangedIndex = changedIndex + 1; while (nextChangedIndex < changedLineIndices.Length && changedLineIndices[nextChangedIndex] <= hunkEnd + contextLines) { hunkEnd = Math.Min(changedLineIndices[nextChangedIndex] + contextLines, maxLineCount - 1); nextChangedIndex++; } var oldLineStart = FirstRealLineNumber(expectedLines, hunkStart); var newLineStart = FirstRealLineNumber(actualLines, hunkStart); var oldLineCount = CountRealLines(expectedLines, hunkStart, hunkEnd); var newLineCount = CountRealLines(actualLines, hunkStart, hunkEnd); diffBuilder.AppendLine($"@@ -{oldLineStart},{oldLineCount} +{newLineStart}," + $"{newLineCount} @@"); for (var lineIndex = hunkStart; lineIndex <= hunkEnd; lineIndex++) { var expectedLine = lineIndex < expectedLines.Count ? expectedLines[lineIndex] : new DiffPiece("", ChangeType.Imaginary, lineIndex + 1); var actualLine = lineIndex < actualLines.Count ? actualLines[lineIndex] : new DiffPiece("", ChangeType.Imaginary, lineIndex + 1); if (expectedLine.Type == ChangeType.Unchanged && actualLine.Type == ChangeType.Unchanged) { diffBuilder.Append(' ').AppendLine(expectedLine.Text ?? ""); continue; } if (expectedLine.Type != ChangeType.Unchanged && expectedLine.Type != ChangeType.Imaginary) { diffBuilder.Append('-').AppendLine(expectedLine.Text ?? ""); } if (actualLine.Type != ChangeType.Unchanged && actualLine.Type != ChangeType.Imaginary) { diffBuilder.Append('+').AppendLine(actualLine.Text ?? ""); } } changedIndex = nextChangedIndex; } return diffBuilder.ToString(); } private static int FirstRealLineNumber(IReadOnlyList lines, int index) { // Search forward from the given index for (var i = index; i < lines.Count; ++i) { if (lines[i].Position.HasValue) return lines[i].Position.Value; } // Search backward from the given index for (var i = Math.Min(index, lines.Count - 1); i >= 0; --i) { if (lines[i].Position.HasValue) return lines[i].Position.Value + 1; } return 1; // Default if no real line is found } private static int CountRealLines(IReadOnlyList lines, int start, int end) { return Enumerable.Range(start, end - start + 1) .Count(i => i < lines.Count && lines[i].Position.HasValue); } } }