using System.Diagnostics; using System.IO; using System.Reflection; using System.Text; using System.Text.Json; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using XFEToolBox.Core.Tools; using XFEToolBox.Tools.BulkTextReplacer; namespace TextReplacer.Validation; internal static class Program { private static readonly string Workspace = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFEToolBox", "CrossVersion", "EditorWorkspaces", "BulkTextReplacer"); private static readonly string Artifacts = Path.Combine(AppContext.BaseDirectory, "test-artifacts"); private static readonly List Checks = []; private static int exitCode; [STAThread] private static int Main(string[] args) { Directory.CreateDirectory(Artifacts); var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/XFEToolBox.WpfCore;component/Resources/Style/ToolThemeResources.xaml") }); app.Startup += async (_, _) => { try { await RunAsync(args); } catch (Exception e) { exitCode = 1; Console.Error.WriteLine(e); } finally { File.WriteAllText(Path.Combine(Artifacts, "results.json"), JsonSerializer.Serialize(new { passed = exitCode == 0, at = DateTimeOffset.Now, checks = Checks }, new JsonSerializerOptions { WriteIndented = true })); app.Shutdown(); } }; app.Run(); return exitCode; } private static void Check(bool condition, string description) { if (!condition) throw new InvalidOperationException("FAIL: " + description); Checks.Add(description); Console.WriteLine("PASS: " + description); } private static async Task ReplaceAsync(MainPageViewModel vm, string source, string search, string replacement, bool sensitive = true) { vm.SourceText = source; vm.InlineSearchText = search; vm.InlineReplacementText = replacement; vm.InlineCaseSensitive = sensitive; await vm.ReplaceTextCommand.ExecuteAsync(null); } private static async Task RunAsync(string[] args) { var vm = new MainPageViewModel(); Check(!vm.HasTextResult && vm.TextProgress == 0 && !vm.CopyTextResultCommand.CanExecute(null), "initial result/progress empty and copy disabled"); foreach (var test in new (string Source, string Search, string Replacement, bool Sensitive, string Expected, int Count)[] { ("你好 世界,你好!", "你好", "欢迎", true, "欢迎 世界,欢迎!", 2), ("Foo foo FOO", "foo", "bar", true, "Foo bar FOO", 1), ("Foo foo FOO", "foo", "bar", false, "bar bar bar", 3), ("a\r\nb\r\na\r\nb", "a\r\nb", "替换\r\n行", true, "替换\r\n行\r\n替换\r\n行", 2), ("a.b [x] $1", ".", "$&", true, "a$&b [x] $1", 1), ("aaaaa", "aa", "x", true, "xxa", 2), ("😀A😀", "😀", "🚀", true, "🚀A🚀", 2), ("delete delete", "delete", "", true, " ", 2), ("aaa", "a", "", true, "", 3), ("", "a", "b", true, "", 0), ("unchanged", "missing", "new", true, "unchanged", 0) }) { await ReplaceAsync(vm, test.Source, test.Search, test.Replacement, test.Sensitive); Check(vm.HasTextResult && vm.ResultText == test.Expected && vm.TextReplacementCount == test.Count && vm.SourceText == test.Source, $"literal replace {Checks.Count}: correct result/count, source preserved"); } await ReplaceAsync(vm, "abc", "", "x"); Check(!vm.HasTextResult && vm.TextStatus.Contains("不能为空") && vm.TextProgress == 0, "empty search rejected without hanging"); await ReplaceAsync(vm, "old old", "old", "new"); Check(vm.CopyTextResultCommand.CanExecute(null) && vm.TextProgress == 1, "success enables result actions and completes progress"); vm.InlineReplacementText = "changed"; Check(!vm.HasTextResult && vm.ResultText == "" && !vm.CopyTextResultCommand.CanExecute(null), "changing rule invalidates stale results"); await vm.ReplaceTextCommand.ExecuteAsync(null); vm.UseTextResultCommand.Execute(null); Check(vm.SourceText == "changed changed" && !vm.HasTextResult, "use-result command fills source for next replacement"); await ReplaceAsync(vm, new string('a', 100000), "a", new string('b', 50)); Check(!vm.HasTextResult && !vm.IsTextBusy && vm.TextStatus.Contains("400 万"), "output expansion limit recovers without allocating huge result"); await ReplaceAsync(vm, "recover", "recover", "done"); Check(vm.ResultText == "done", "replacement works again after rejected operation"); await TestFilesAsync(); await TestViewAsync(); await TestHostAsync(args); Console.WriteLine($"ALL {Checks.Count} CHECKS PASSED. Artifacts: {Artifacts}"); } private static async Task TestFilesAsync() { string root = Path.Combine(Artifacts, "files-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(root, "child")); string utf8 = Path.Combine(root, "utf8.txt"), utf16 = Path.Combine(root, "child", "utf16.TXT"), utf32 = Path.Combine(root, "utf32.txt"), skipped = Path.Combine(root, "skip.md"); var encodings = new[] { (Path: utf8, Encoding: (Encoding)new UTF8Encoding(true), Text: "old old"), (Path: utf16, Encoding: (Encoding)new UnicodeEncoding(false, true), Text: "old\r\nX"), (Path: utf32, Encoding: (Encoding)new UTF32Encoding(false, true), Text: "old") }; foreach (var file in encodings) await File.WriteAllTextAsync(file.Path, file.Text, file.Encoding); await File.WriteAllTextAsync(skipped, "old"); var original = encodings.ToDictionary(x => x.Path, x => File.ReadAllBytes(x.Path)); var vm = new MainPageViewModel { FolderPath = root, Extensions = ".txt", SearchText = "old", ReplacementText = "new", CreateBackups = true, IncludeSubfolders = true }; await ReplaceAsync(vm, "old only in textbox", "old", "TEXT"); Check(encodings.All(x => File.ReadAllBytes(x.Path).SequenceEqual(original[x.Path])) && Directory.GetFiles(root, "*.bak", SearchOption.AllDirectories).Length == 0, "plain text replacement never touches selected files or creates backups"); Check(vm.SearchText == "old" && vm.ReplacementText == "new", "plain text rules independent of file rules"); await vm.ReplaceCommand.ExecuteAsync(null); Check(vm.Status.Contains("修改 3 个文件,共替换 4 处") && vm.Report.Contains("文件批量替换报告"), "existing recursive extension-filtered file replacement works"); Check(vm.ResultText == "TEXT only in textbox", "file operation preserves plain text result"); foreach (var file in encodings) { Check(File.ReadAllText(file.Path, file.Encoding) == file.Text.Replace("old", "new") && File.ReadAllBytes(file.Path).AsSpan().StartsWith(file.Encoding.GetPreamble()), $"file content/encoding/BOM preserved: {Path.GetFileName(file.Path)}"); Check(File.ReadAllBytes(file.Path + ".bak").SequenceEqual(original[file.Path]), $"original byte-for-byte backup: {Path.GetFileName(file.Path)}"); } Check(File.ReadAllText(skipped) == "old" && !File.Exists(skipped + ".bak"), "excluded extension remains untouched"); vm.FolderPath = ""; vm.FilePath = utf8; vm.SearchText = "NEW"; vm.ReplacementText = "single"; vm.CaseSensitive = false; vm.CreateBackups = false; await vm.ReplaceCommand.ExecuteAsync(null); Check(File.ReadAllText(utf8) == "single single" && vm.Status.Contains("修改 1 个文件,共替换 2 处"), "single file and ignore-case mode preserved"); } private static async Task TestViewAsync() { var trace = new BindingTrace(); PresentationTraceSources.DataBindingSource.Listeners.Add(trace); PresentationTraceSources.DataBindingSource.Switch.Level = SourceLevels.Error; var page = new MainPage(); var vm = (MainPageViewModel)page.DataContext; var window = new Window { Content = page, Width = 1060, Height = 730, Left = -30000, Top = -30000, ShowInTaskbar = false, WindowStyle = WindowStyle.None }; window.Show(); var tabs = Find(page)!; Check(tabs.Items.Count == 2 && tabs.SelectedIndex == 0 && ((TabItem)tabs.Items[0]).Header.ToString() == "普通文本替换" && ((TabItem)tabs.Items[1]).Header.ToString() == "文件批量替换", "two tabs in requested order, plain text selected by default"); await ReplaceAsync(vm, "你好,世界!\n欢迎使用文本替换工具。\n再次问候:你好,世界!", "世界", "XFEToolBox"); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); SaveView(page, "text-replacer.png"); window.Width = 800; window.Height = 580; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); SaveView(page, "text-replacer-minimum.png"); Check(Find(page, box => box.IsReadOnly && box.Text == vm.ResultText)!.ActualHeight >= 75, "minimum layout keeps result editor readable"); string result = vm.ResultText; tabs.SelectedIndex = 1; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); Check(vm.ResultText == result && vm.HasTextResult, "switching to file tab retains plain text state"); vm.SearchText = "file-only"; tabs.SelectedIndex = 0; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); Check(vm.InlineSearchText == "世界" && vm.ResultText == result, "switching tabs does not mix replacement fields"); int ticks = 0; var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1) }; timer.Tick += (_, _) => ticks++; timer.Start(); var task = ReplaceAsync(vm, new string('a', 1000000), "a", "b"); Check(!vm.CanEditText && !vm.ReplaceTextCommand.CanExecute(null), "running operation disables duplicate execution/editing"); await task; timer.Stop(); Check(vm.ResultText.Length == 1000000 && vm.TextReplacementCount == 1000000 && !vm.IsTextBusy, "large text processed asynchronously and UI state restored"); await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); Check(trace.Errors.Count == 0, "no WPF binding errors: " + string.Join(" | ", trace.Errors)); window.Close(); PresentationTraceSources.DataBindingSource.Listeners.Remove(trace); } private static T? Find(DependencyObject root, Func? predicate = null) where T : DependencyObject { if (root is T found && (predicate is null || predicate(found))) return found; for (int i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) if (Find(VisualTreeHelper.GetChild(root, i), predicate) is T child) return child; return null; } private static void SaveView(FrameworkElement page, string name) { page.UpdateLayout(); var image = new RenderTargetBitmap((int)Math.Ceiling(page.ActualWidth), (int)Math.Ceiling(page.ActualHeight), 96, 96, PixelFormats.Pbgra32); image.Render(page); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(image)); using var file = File.Create(Path.Combine(Artifacts, name)); encoder.Save(file); } private static async Task TestHostAsync(string[] args) { var host = typeof(XFEToolBox.Client.Models.LauncherItem).Assembly; var service = host.GetType("XFEToolBox.Client.Utilities.ToolProjectRunService", true)!; var manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(Workspace, "manifest.json")), new JsonSerializerOptions(JsonSerializerDefaults.Web))!; Check(manifest.Name == "文本替换工具" && manifest.Id == "xfestudio.bulk-text-replacer" && manifest.Version == "1.2.0", "renamed manifest keeps existing tool ID and increments version"); async Task BuildAsync(string root) { var task = (Task)service.GetMethod("BuildAsync", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, [root, manifest, CancellationToken.None, null])!; await task.WaitAsync(TimeSpan.FromMinutes(3)); var result = task.GetType().GetProperty("Result")!.GetValue(task)!; Check((bool)result.GetType().GetProperty("Success")!.GetValue(result)!, "production host compilation: " + result.GetType().GetProperty("Message")!.GetValue(result)); } await BuildAsync(Workspace); if (args.Contains("--check-package")) { string package = Path.Combine(Path.GetDirectoryName(Workspace)!, "Packages", $"{manifest.Id}-{manifest.Version}.xfetool"); string extracted = Path.Combine(Artifacts, "package-" + Guid.NewGuid().ToString("N")); await (Task)service.GetMethod("ExtractAndValidatePackageAsync", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, [package, extracted, manifest.Id, manifest.Version, CancellationToken.None])!; var files = Directory.GetFiles(extracted, "*", SearchOption.AllDirectories); Check(files.Length == Directory.GetFiles(Workspace, "*", SearchOption.AllDirectories).Length && files.All(file => File.ReadAllBytes(file).SequenceEqual(File.ReadAllBytes(Path.Combine(Workspace, Path.GetRelativePath(extracted, file))))), "delivered package validated and identical to tested source"); await BuildAsync(extracted); } if (args.Contains("--register")) { await (Task)host.GetType("XFEToolBox.Client.Utilities.ToolProjectWorkspaceService", true)!.GetMethod("RememberProjectAsync", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, [Workspace])!; Console.WriteLine("Project name refreshed: " + manifest.Name); } } private sealed class BindingTrace : TraceListener { public List Errors { get; } = []; public override void Write(string? message) { if (!string.IsNullOrEmpty(message)) Errors.Add(message); } public override void WriteLine(string? message) => Write(message); } }