XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEToolBox

新增端到端验证测试项目及主页刷新优化

增加 ProgramDebugger.Validation 和 TextReplacer.Validation 两个端到端验证测试项目,覆盖自动化功能、界面、宿主编译和包校验等场景,支持参数传递、输出解析、编码、UI 绑定、窗口布局、文件操作等多项验证,自动生成测试报告和截图。优化主页卡片和活动列表刷新逻辑,提升性能和 UI 响应性,避免集合重建和图标重复加载。新增 HomeDashboardRefreshTests,确保主页刷新行为正确。更新 README.md,补充测试项目说明和使用方法。

22f191d
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

11 个文件 +958 -55
Added XFEToolBox.Client.Wpf.Test/HomeDashboardRefreshTests.cs +124 -0
@@ -0,0 +1,124 @@
1 using System.Collections.ObjectModel;
2 using System.Collections.Specialized;
3 using XFEToolBox.Client.Models;
4 using XFEToolBox.Client.ViewModel.Pages;
5
6 namespace XFEToolBox.Client.Wpf.Test;
7
8 public static class HomeDashboardRefreshTests
9 {
10 [Test]
11 public static void ReturningHomeReusesCardsAndIconsWithoutResettingTheVisualTree() => RunSta(() =>
12 {
13 var cards = new ObservableCollection<LauncherItemViewModel>();
14 MainPageViewModel.UpdateLauncherItems(cards, Enumerable.Range(0, 8).Select(index => Item(index)).ToArray());
15 var originals = cards.ToArray();
16 var icons = cards.Select(card => card.IconSource).ToArray();
17 var iconTasks = cards.Select(card => card.IconLoadingTask).ToArray();
18 var collectionChanges = 0;
19 cards.CollectionChanged += (_, _) => collectionChanges++;
20 var executedRevision = 0;
21
22 for (var revision = 1; revision <= 250; revision++)
23 {
24 var currentRevision = revision;
25 MainPageViewModel.UpdateLauncherItems(cards, Enumerable.Range(0, 8).Select(index => Item(index,
26 execute: () => { executedRevision = currentRevision; return Task.CompletedTask; })).ToArray());
27 }
28
29 Ensure(collectionChanges == 0, $"重复打开主页触发了 {collectionChanges} 次未变化的卡片集合更新。");
30 for (var index = 0; index < cards.Count; index++)
31 {
32 Ensure(ReferenceEquals(cards[index], originals[index]), "未变化的卡片没有复用。");
33 Ensure(ReferenceEquals(cards[index].IconSource, icons[index]), "未变化的图标被重新创建。");
34 Ensure(ReferenceEquals(cards[index].IconLoadingTask, iconTasks[index]), "未变化的图标触发了重新加载。");
35 }
36 cards[0].ExecuteAsync().GetAwaiter().GetResult();
37 Ensure(executedRevision == 250, "复用卡片后执行了旧快照的启动逻辑。");
38 Console.WriteLine("主页重复刷新 250 次:未变化卡片的集合变更 0 次,8 个图标均复用。");
39 });
40
41 [Test]
42 public static void PinReorderingAndCatalogUpdatesStillRefreshTheAffectedCards() => RunSta(() =>
43 {
44 var cards = new ObservableCollection<LauncherItemViewModel>();
45 MainPageViewModel.UpdateLauncherItems(cards, [Item(0), Item(1), Item(2)]);
46 var originals = cards.ToArray();
47 var pinNotifications = 0;
48 originals[2].PropertyChanged += (_, args) =>
49 {
50 if (args.PropertyName == nameof(LauncherItemViewModel.PinGlyph)) pinNotifications++;
51 };
52 var resetCount = 0;
53 cards.CollectionChanged += (_, args) =>
54 {
55 if (args.Action == NotifyCollectionChangedAction.Reset) resetCount++;
56 };
57
58 MainPageViewModel.UpdateLauncherItems(cards, [Item(2, pinned: true), Item(0), Item(1)]);
59 Ensure(ReferenceEquals(cards[0], originals[2]) && ReferenceEquals(cards[1], originals[0]),
60 "固定项排序没有移动已有卡片。");
61 Ensure(pinNotifications == 1, "固定项变化没有通知星标绑定。");
62
63 MainPageViewModel.UpdateLauncherItems(cards, [Item(2, pinned: true), Item(0, title: "更新后的工具"), Item(3)]);
64 Ensure(cards.Select(card => card.Item.TargetId).SequenceEqual(["home-test-2", "home-test-0", "home-test-3"]),
65 "主页卡片没有同步新增、移除或排序变化。");
66 Ensure(cards[1].Title == "更新后的工具" && !ReferenceEquals(cards[1], originals[0]), "目录修改没有更新对应卡片。");
67 Ensure(ReferenceEquals(cards[0], originals[2]), "局部修改重建了无关卡片。");
68 Ensure(resetCount == 0, "局部刷新仍然清空了整张卡片列表。");
69
70 MainPageViewModel.UpdateLauncherItems(cards, []);
71 Ensure(cards.Count == 0, "空目录没有清除已移除条目。");
72 });
73
74 [Test]
75 public static void ActivityProgressDoesNotRecreateExistingActivityRows()
76 {
77 var item = new ActivityItem("home-activity", ActivityKind.General, "下载测试", false);
78 var rows = new ObservableCollection<ActivityItem>();
79 MainPageViewModel.SynchronizeItems(rows, [item]);
80 var collectionChanges = 0;
81 rows.CollectionChanged += (_, _) => collectionChanges++;
82
83 for (var progress = 0; progress <= 100; progress++)
84 {
85 item.Progress = progress;
86 MainPageViewModel.SynchronizeItems(rows, [item]);
87 }
88
89 Ensure(collectionChanges == 0 && ReferenceEquals(rows[0], item) && rows[0].Progress == 100,
90 "活动进度变化重建了行或未保留进度绑定。");
91 var next = new ActivityItem("next-activity", ActivityKind.General, "构建测试", false);
92 MainPageViewModel.SynchronizeItems(rows, [next, item]);
93 Ensure(rows.Count == 2 && ReferenceEquals(rows[1], item), "新增活动未保留已有行。");
94 }
95
96 private static LauncherItem Item(int index, string? title = null, bool pinned = false, Func<Task>? execute = null) => new()
97 {
98 Kind = LauncherItemKind.Tool,
99 TargetId = $"home-test-{index}",
100 Title = title ?? $"工具 {index}",
101 IconReference = "/Resources/Image/default_tool_icon.png",
102 IsPinned = pinned,
103 ExecuteAsync = execute ?? (() => Task.CompletedTask)
104 };
105
106 private static void RunSta(Action action)
107 {
108 Exception? failure = null;
109 var thread = new Thread(() =>
110 {
111 try { action(); }
112 catch (Exception exception) { failure = exception; }
113 }) { IsBackground = true };
114 thread.SetApartmentState(ApartmentState.STA);
115 thread.Start();
116 Ensure(thread.Join(TimeSpan.FromSeconds(15)), "主页刷新回归测试超时。");
117 if (failure is not null) throw new InvalidOperationException("主页刷新回归失败。", failure);
118 }
119
120 private static void Ensure(bool condition, string message)
121 {
122 if (!condition) throw new InvalidOperationException(message);
123 }
124 }
Modified XFEToolBox/Utilities/Helpers/BilibiliHelper.cs +1 -1
@@ -48,7 +48,7 @@ public static class BilibiliHelper
48 48 Func<XFEJsonNode, XFEJsonNode?> selectItems,
49 49 CancellationToken cancellationToken)
50 50 {
51 var responseContent = await HttpClient.GetStringAsync(requestUri, cancellationToken);
51 var responseContent = await HttpClient.GetStringAsync(requestUri, cancellationToken).ConfigureAwait(false);
52 52 XFEJsonNode root = responseContent;
53 53
54 54 if (root["code"]?.GetInt32() != 0)
Modified XFEToolBox/Utilities/LauncherService.cs +38 -14
@@ -11,6 +11,11 @@ namespace XFEToolBox.Client.Utilities;
11 11 public static class LauncherService
12 12 {
13 13 private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
14 private static readonly object CatalogCacheLock = new();
15 private static string? cachedToolCatalogJson;
16 private static string? cachedSoftwareCatalogJson;
17 private static ToolPackageSummary[] cachedTools = [];
18 private static SoftwareCatalogItem[] cachedSoftware = [];
14 19
15 20 public static async Task<IReadOnlyList<LauncherItem>> SearchAsync(string? query, int maximumCount = 20)
16 21 {
@@ -45,7 +50,7 @@ public static class LauncherService
45 50
46 51 public static async Task<IReadOnlyList<LauncherItem>> GetQuickAccessAsync()
47 52 {
48 var allItems = await GetAllItemsAsync();
53 var allItems = await GetAllItemsAsync().ConfigureAwait(false);
49 54 var index = allItems
50 55 .GroupBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
51 56 .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
@@ -67,7 +72,8 @@ public static class LauncherService
67 72 return result.Take(8).ToArray();
68 73 }
69 74
70 public static async Task<IReadOnlyList<LauncherItem>> GetRecentlyUpdatedToolsAsync(int maximumCount = 4)
75 public static Task<IReadOnlyList<LauncherItem>> GetRecentlyUpdatedToolsAsync(int maximumCount = 4) =>
76 Task.Run<IReadOnlyList<LauncherItem>>(() =>
71 77 {
72 78 var tools = ReadToolCatalog()
73 79 .OrderByDescending(tool => tool.UpdatedAtUtc)
@@ -75,9 +81,13 @@ public static class LauncherService
75 81 .ToArray();
76 82 var recent = GetRecentIndex();
77 83 return tools.Select(tool => CreateToolItem(tool, recent)).ToArray();
78 }
84 });
85
86 // JSON snapshots contain embedded icons, and project history may reference slow
87 // disks. Keep both parsing and filesystem checks off the page's dispatcher.
88 public static Task<IReadOnlyList<LauncherItem>> GetAllItemsAsync() => Task.Run(BuildAllItemsAsync);
79 89
80 public static async Task<IReadOnlyList<LauncherItem>> GetAllItemsAsync()
90 private static async Task<IReadOnlyList<LauncherItem>> BuildAllItemsAsync()
81 91 {
82 92 var recent = GetRecentIndex();
83 93 var items = new List<LauncherItem>();
@@ -232,24 +242,38 @@ public static class LauncherService
232 242
233 243 private static ToolPackageSummary[] ReadToolCatalog()
234 244 {
235 try
245 var json = AppCacheProfile.ToolCatalogJson;
246 lock (CatalogCacheLock)
236 247 {
237 return string.IsNullOrWhiteSpace(AppCacheProfile.ToolCatalogJson)
238 ? []
239 : JsonSerializer.Deserialize<ToolPackageSummary[]>(AppCacheProfile.ToolCatalogJson, JsonOptions) ?? [];
248 if (ReferenceEquals(json, cachedToolCatalogJson)) return cachedTools;
249 try
250 {
251 cachedTools = string.IsNullOrWhiteSpace(json)
252 ? []
253 : JsonSerializer.Deserialize<ToolPackageSummary[]>(json, JsonOptions) ?? [];
254 }
255 catch (JsonException) { cachedTools = []; }
256 cachedToolCatalogJson = json;
257 return cachedTools;
240 258 }
241 catch (JsonException) { return []; }
242 259 }
243 260
244 261 private static SoftwareCatalogItem[] ReadSoftwareCatalog()
245 262 {
246 try
263 var json = AppCacheProfile.SoftwareCatalogJson;
264 lock (CatalogCacheLock)
247 265 {
248 return string.IsNullOrWhiteSpace(AppCacheProfile.SoftwareCatalogJson)
249 ? []
250 : (JsonSerializer.Deserialize<SoftwareCatalogResponse>(AppCacheProfile.SoftwareCatalogJson, JsonOptions)?.Items ?? []);
266 if (ReferenceEquals(json, cachedSoftwareCatalogJson)) return cachedSoftware;
267 try
268 {
269 cachedSoftware = string.IsNullOrWhiteSpace(json)
270 ? []
271 : JsonSerializer.Deserialize<SoftwareCatalogResponse>(json, JsonOptions)?.Items ?? [];
272 }
273 catch (JsonException) { cachedSoftware = []; }
274 cachedSoftwareCatalogJson = json;
275 return cachedSoftware;
251 276 }
252 catch (JsonException) { return []; }
253 277 }
254 278
255 279 private static IReadOnlyDictionary<string, RecentUsageEntry> GetRecentIndex()
Modified XFEToolBox/ViewModel/Pages/LauncherItemViewModel.cs +20 -1
@@ -15,7 +15,7 @@ public partial class LauncherItemViewModel : ObservableObject
15 15 IconLoadingTask = LoadIconAsync();
16 16 }
17 17
18 public LauncherItem Item { get; }
18 public LauncherItem Item { get; private set; }
19 19 internal Task IconLoadingTask { get; }
20 20 public string Title => Item.Title;
21 21 public string Subtitle => Item.Subtitle;
@@ -25,6 +25,25 @@ public partial class LauncherItemViewModel : ObservableObject
25 25 public string PinText => IsPinned ? "取消固定" : "固定";
26 26 public string PinGlyph => IsPinned ? "★" : "☆";
27 27
28 internal bool TryUpdate(LauncherItem item)
29 {
30 if (!string.Equals(Item.Key, item.Key, StringComparison.OrdinalIgnoreCase)
31 || Item.Title != item.Title || Item.Subtitle != item.Subtitle
32 || Item.Detail != item.Detail || Item.IconReference != item.IconReference)
33 return false;
34
35 var pinChanged = Item.IsPinned != item.IsPinned;
36 // Keep the decoded icon and bindings, but use the current launch action.
37 Item = item;
38 if (pinChanged)
39 {
40 OnPropertyChanged(nameof(IsPinned));
41 OnPropertyChanged(nameof(PinText));
42 OnPropertyChanged(nameof(PinGlyph));
43 }
44 return true;
45 }
46
28 47 [ObservableProperty] private ImageSource iconSource;
29 48 [ObservableProperty] private bool isEnabled = true;
30 49
Modified XFEToolBox/ViewModel/Pages/MainPageViewModel.cs +105 -39
@@ -32,7 +32,11 @@ public partial class MainPageViewModel : ObservableObject
32 32 private readonly DispatcherTimer adminRefreshTimer;
33 33 private bool isAdminOverviewLoading;
34 34 private bool hasAdminOverviewSnapshot;
35 private bool hasRecentUsageSnapshot;
35 private bool isPageLoaded;
36 private bool dashboardRefreshRequested;
37 private Task? dashboardRefreshTask;
38 private int dashboardRefreshQueued;
39 private int activityRefreshQueued;
36 40
37 41 public MainPage MainPage { get; }
38 42
@@ -44,7 +48,7 @@ public partial class MainPageViewModel : ObservableObject
44 48 ClientSession.SessionChanged += ClientSession_SessionChanged;
45 49 RecentUsageService.Changed += RecentUsageService_Changed;
46 50 PinnedItemService.Changed += DashboardData_Changed;
47 ActivityCenterService.Changed += DashboardData_Changed;
51 ActivityCenterService.Changed += ActivityCenterService_Changed;
48 52 if (Application.Current is App app)
49 53 app.GlobalHotkeyStatusChanged += (_, _) => MainPage.Dispatcher.InvokeAsync(
50 54 () => OnPropertyChanged(nameof(LauncherHotkeyHint)));
@@ -85,39 +89,66 @@ public partial class MainPageViewModel : ObservableObject
85 89 ? SystemProfile.LauncherHotkey.Replace("+", " + ", StringComparison.Ordinal)
86 90 : "快捷键已禁用";
87 91
88 private async void MainPage_Loaded(object sender, System.Windows.RoutedEventArgs e)
92 private void MainPage_Loaded(object sender, System.Windows.RoutedEventArgs e)
89 93 {
90 if (!hasRecentUsageSnapshot)
91 RefreshRecentUsage();
92 await RefreshDashboardAsync();
94 isPageLoaded = true;
95 QueueDashboardRefresh();
96 QueueActivityRefresh();
93 97 if (!MainPage.mainCarousel.HasItems)
94 98 _ = MainPage.Dispatcher.InvokeAsync(
95 async () => await ReloadAsync(),
99 async () => { if (isPageLoaded) await ReloadAsync(); },
96 100 DispatcherPriority.ContextIdle);
97 await LoadAdminOverviewAsync();
98 if (ClientSession.IsAdministrator) adminRefreshTimer.Start();
101 _ = MainPage.Dispatcher.InvokeAsync(async () =>
102 {
103 if (!isPageLoaded) return;
104 await LoadAdminOverviewAsync();
105 if (isPageLoaded && ClientSession.IsAdministrator) adminRefreshTimer.Start();
106 }, DispatcherPriority.Background);
99 107 }
100 108
101 private void MainPage_Unloaded(object sender, RoutedEventArgs e) => adminRefreshTimer.Stop();
109 private void MainPage_Unloaded(object sender, RoutedEventArgs e)
110 {
111 isPageLoaded = false;
112 adminRefreshTimer.Stop();
113 }
102 114
103 115 private async void AdminRefreshTimer_Tick(object? sender, EventArgs e) => await LoadAdminOverviewAsync();
104 116
105 117 private void ClientSession_SessionChanged(object? sender, EventArgs e) => MainPage.Dispatcher.InvokeAsync(async () =>
106 118 {
119 if (!isPageLoaded) return;
107 120 await LoadAdminOverviewAsync();
108 if (ClientSession.IsAdministrator && MainPage.IsVisible) adminRefreshTimer.Start();
121 if (ClientSession.IsAdministrator && isPageLoaded) adminRefreshTimer.Start();
109 122 else adminRefreshTimer.Stop();
110 });
123 }, DispatcherPriority.Background);
124
125 private void RecentUsageService_Changed(object? sender, EventArgs e) => QueueDashboardRefresh();
111 126
112 private void RecentUsageService_Changed(object? sender, EventArgs e) =>
113 MainPage.Dispatcher.InvokeAsync(async () =>
127 private void DashboardData_Changed(object? sender, EventArgs e) => QueueDashboardRefresh();
128
129 private void ActivityCenterService_Changed(object? sender, EventArgs e) => QueueActivityRefresh();
130
131 private void QueueDashboardRefresh()
132 {
133 if (!Volatile.Read(ref isPageLoaded)) return;
134 if (Interlocked.Exchange(ref dashboardRefreshQueued, 1) != 0) return;
135 _ = MainPage.Dispatcher.InvokeAsync(() =>
114 136 {
115 RefreshRecentUsage();
116 await RefreshDashboardAsync();
117 });
137 Interlocked.Exchange(ref dashboardRefreshQueued, 0);
138 if (isPageLoaded) _ = RefreshDashboardAsync();
139 }, DispatcherPriority.Background);
140 }
118 141
119 private void DashboardData_Changed(object? sender, EventArgs e) =>
120 MainPage.Dispatcher.InvokeAsync(RefreshDashboardAsync);
142 private void QueueActivityRefresh()
143 {
144 if (!Volatile.Read(ref isPageLoaded)) return;
145 if (Interlocked.Exchange(ref activityRefreshQueued, 1) != 0) return;
146 _ = MainPage.Dispatcher.InvokeAsync(() =>
147 {
148 Interlocked.Exchange(ref activityRefreshQueued, 0);
149 if (isPageLoaded) RefreshActivities();
150 }, DispatcherPriority.Background);
151 }
121 152
122 153 public async Task OpenRecentItemAsync(RecentUsageCardViewModel card)
123 154 {
@@ -162,9 +193,9 @@ public partial class MainPageViewModel : ObservableObject
162 193
163 194 public bool TogglePinned(LauncherItemViewModel item) => item.TogglePinned();
164 195
165 private void RefreshRecentUsage()
196 private void RefreshRecentUsage(IReadOnlyList<RecentUsageEntry> savedEntries)
166 197 {
167 var entries = RecentUsageService.GetRecent()
198 var entries = savedEntries
168 199 .Where(entry => entry.Kind is RecentUsageKind.Tool or RecentUsageKind.Software or RecentUsageKind.Project)
169 200 .Take(MaximumVisibleRecentItems)
170 201 .ToArray();
@@ -189,37 +220,72 @@ public partial class MainPageViewModel : ObservableObject
189 220 while (RecentItems.Count > recent.Length)
190 221 RecentItems.RemoveAt(RecentItems.Count - 1);
191 222
192 hasRecentUsageSnapshot = true;
193 223 RecentItemsVisibility = recent.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
194 224 RecentEmptyVisibility = recent.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
195 225 RecentUsageCountText = $"{recent.Length} 项";
196 226 }
197 227
198 private async Task RefreshDashboardAsync()
228 private Task RefreshDashboardAsync()
199 229 {
200 var quickAccess = (await LauncherService.GetQuickAccessAsync())
201 .Select(item => new LauncherItemViewModel(item))
202 .ToArray();
203 var latestTools = (await LauncherService.GetRecentlyUpdatedToolsAsync())
204 .Select(item => new LauncherItemViewModel(item))
205 .ToArray();
206 var activities = ActivityCenterService.GetSnapshot(4);
230 dashboardRefreshRequested = true;
231 if (dashboardRefreshTask is { IsCompleted: false }) return dashboardRefreshTask;
232 return dashboardRefreshTask = RefreshDashboardCoreAsync();
233 }
207 234
208 ReplaceItems(QuickAccessItems, quickAccess);
209 ReplaceItems(RecentlyUpdatedTools, latestTools);
210 ReplaceItems(ActivityItems, activities);
211 QuickAccessVisibility = QuickAccessItems.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
212 LatestToolsVisibility = RecentlyUpdatedTools.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
235 private async Task RefreshDashboardCoreAsync()
236 {
237 while (dashboardRefreshRequested && isPageLoaded)
238 {
239 dashboardRefreshRequested = false;
240 try
241 {
242 var recentTask = Task.Run(() => RecentUsageService.GetRecent());
243 var quickTask = LauncherService.GetQuickAccessAsync();
244 var latestTask = LauncherService.GetRecentlyUpdatedToolsAsync();
245 await Task.WhenAll(recentTask, quickTask, latestTask);
246 if (!isPageLoaded) return;
247 if (dashboardRefreshRequested) continue;
248
249 RefreshRecentUsage(await recentTask);
250 UpdateLauncherItems(QuickAccessItems, await quickTask);
251 UpdateLauncherItems(RecentlyUpdatedTools, await latestTask);
252 QuickAccessVisibility = QuickAccessItems.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
253 LatestToolsVisibility = RecentlyUpdatedTools.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
254 }
255 catch (Exception exception)
256 {
257 Debug.WriteLine($"主页本地内容刷新失败:{exception}");
258 }
259 }
260 }
261
262 private void RefreshActivities()
263 {
264 SynchronizeItems(ActivityItems, ActivityCenterService.GetSnapshot(4));
213 265 ActivitySectionVisibility = ActivityItems.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
214 266 ActivityCountText = ActivityCenterService.ActiveCount > 0
215 267 ? $"{ActivityCenterService.ActiveCount} 项进行中"
216 268 : ActivityItems.Count > 0 ? "最近活动" : "暂无活动";
217 269 }
218 270
219 private static void ReplaceItems<T>(ObservableCollection<T> target, IReadOnlyList<T> source)
271 internal static void UpdateLauncherItems(ObservableCollection<LauncherItemViewModel> target, IReadOnlyList<LauncherItem> source)
272 {
273 var existing = target.ToDictionary(card => card.Item.Key, StringComparer.OrdinalIgnoreCase);
274 var cards = source.Select(item => existing.TryGetValue(item.Key, out var card) && card.TryUpdate(item)
275 ? card : new LauncherItemViewModel(item)).ToArray();
276 SynchronizeItems(target, cards);
277 }
278
279 internal static void SynchronizeItems<T>(ObservableCollection<T> target, IReadOnlyList<T> source)
220 280 {
221 target.Clear();
222 foreach (var item in source) target.Add(item);
281 for (var index = 0; index < source.Count; index++)
282 {
283 if (index < target.Count && EqualityComparer<T>.Default.Equals(target[index], source[index])) continue;
284 var previousIndex = target.IndexOf(source[index]);
285 if (previousIndex >= 0) target.Move(previousIndex, index);
286 else target.Insert(index, source[index]);
287 }
288 while (target.Count > source.Count) target.RemoveAt(target.Count - 1);
223 289 }
224 290
225 291 private static string CreateRecentUsageKey(RecentUsageEntry entry) =>
@@ -321,7 +387,7 @@ public partial class MainPageViewModel : ObservableObject
321 387
322 388 foreach (var downloadedCover in downloadedCovers.OfType<DownloadedCover>())
323 389 {
324 var image = CreateBitmapImage(downloadedCover.ImageBytes);
390 var image = await Task.Run(() => CreateBitmapImage(downloadedCover.ImageBytes));
325 391 var video = downloadedCover.Candidate.Video;
326 392 var videoUrl = $"https://www.bilibili.com/video/{video.Bvid}";
327 393 carouselItems.Add(new CarouselImageItem
Added tests/ProgramDebugger.Validation/Program.cs +415 -0
@@ -0,0 +1,415 @@
1 using System.Diagnostics;
2 using System.IO;
3 using System.Reflection;
4 using System.Text;
5 using System.Text.Json;
6 using System.Windows;
7 using System.Windows.Controls;
8 using System.Windows.Media;
9 using System.Windows.Media.Imaging;
10 using System.Windows.Threading;
11 using XFEToolBox.Core.Tools;
12 using XFEToolBox.Tools.ProgramDebugger;
13
14 namespace ProgramDebugger.Validation;
15
16 internal static class Program
17 {
18 private static readonly string Workspace = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFEToolBox", "CrossVersion", "EditorWorkspaces", "ProgramDebugger");
19 private static readonly string Artifacts = Path.Combine(AppContext.BaseDirectory, "test-artifacts");
20 private static readonly List<string> Results = [];
21 private static int exitCode;
22 private static string Executable => Environment.ProcessPath!;
23
24 [STAThread]
25 private static int Main(string[] args)
26 {
27 if (args.FirstOrDefault() == "--fixture") return FixtureAsync(args.Skip(1).ToArray()).GetAwaiter().GetResult();
28 Directory.CreateDirectory(Artifacts);
29 var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
30 app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/XFEToolBox.WpfCore;component/Resources/Style/ToolThemeResources.xaml") });
31 app.Startup += async (_, _) =>
32 {
33 try { await TestAsync(args.Contains("--register"), args.Contains("--check-package")); }
34 catch (Exception e) { Console.Error.WriteLine(e); exitCode = 1; }
35 finally
36 {
37 File.WriteAllText(Path.Combine(Artifacts, "results.json"), JsonSerializer.Serialize(new { time = DateTimeOffset.Now, passed = exitCode == 0, checks = Results }, new JsonSerializerOptions { WriteIndented = true }));
38 app.Shutdown();
39 }
40 };
41 app.Run();
42 return exitCode;
43 }
44
45 private static void Check(bool value, string name)
46 {
47 if (!value) throw new InvalidOperationException("FAIL: " + name);
48 Results.Add(name); Console.WriteLine("PASS: " + name);
49 }
50 private static LaunchOptions Options(string mode, string extra = "") => new(Executable, "--fixture " + mode + " " + extra, Artifacts, "UTF-8");
51 private static async Task<(RunResult Run, byte[] Output, byte[] Error)> RunFixtureAsync(string mode, string extra = "")
52 {
53 using var session = new ProcessSession();
54 var result = await session.RunAsync(Options(mode, extra)).WaitAsync(TimeSpan.FromSeconds(15));
55 return (result, session.Output.Snapshot(), session.Error.Snapshot());
56 }
57 private static async Task WaitUntilAsync(Func<bool> condition)
58 {
59 var timeout = Stopwatch.StartNew();
60 while (!condition()) { if (timeout.Elapsed > TimeSpan.FromSeconds(8)) throw new TimeoutException(); await Task.Delay(15); }
61 }
62
63 private static async Task TestAsync(bool register, bool checkPackage)
64 {
65 MakeIcon();
66 var simple = await RunFixtureAsync("json", "\"中文 参数\" \"C:\\path with space\\sample.txt\"");
67 Check(simple.Run.ExitCode == 0 && simple.Run.ProcessId != Environment.ProcessId, "real child PID and normal exit");
68 using (var json = JsonDocument.Parse(simple.Output))
69 {
70 Check(json.RootElement.GetProperty("args")[0].GetString() == "中文 参数", "quoted Unicode arguments retained");
71 Check(json.RootElement.GetProperty("args")[1].GetString() == @"C:\path with space\sample.txt", "quoted file path retained");
72 Check(json.RootElement.GetProperty("cwd").GetString() == Artifacts, "working directory applied");
73 }
74 Check(Encoding.UTF8.GetString(simple.Error) == "warning-only", "stderr isolated without trailing newline");
75 Check(ResultParser.Parse(simple.Output, Encoding.UTF8).Format.StartsWith("JSON"), "pretty multiline JSON parsed");
76
77 foreach (var entry in new[] { ("ndjson", "JSON Lines"), ("xml", "XML"), ("csv", "CSV"), ("tsv", "TSV"), ("kv", "键值对"), ("text", "文本"), ("binary", "二进制"), ("bom", "JSON") })
78 {
79 var result = await RunFixtureAsync(entry.Item1);
80 var parsed = ResultParser.Parse(result.Output, Encoding.UTF8);
81 Check(parsed.Format.StartsWith(entry.Item2), $"real output: {entry.Item1} -> {parsed.Format}");
82 if (entry.Item1 == "binary") Check(result.Output.SequenceEqual(new byte[] { 0, 1, 2, 0xFF, 0xFE, 0x7F, 65, 0 }), "binary output byte-for-byte fidelity");
83 if (entry.Item1 == "csv") Check(parsed.Content.Contains("line1\r\nline2") && parsed.Content.Contains("a,\"b\""), "CSV quoted comma, quote escape and multiline cell");
84 }
85 foreach (string scalar in new[] { "true", "null", "123456789012345678901234567890", "\"字符串\"", "[1,false,null]" })
86 Check(ResultParser.Parse(Encoding.UTF8.GetBytes(scalar), Encoding.UTF8).Format.StartsWith("JSON"), "JSON scalar/array: " + scalar);
87 Check(ResultParser.Parse("{broken"u8.ToArray(), Encoding.UTF8, "JSON").Format.Contains("失败"), "invalid JSON preserved as raw text");
88 Check(ResultParser.Parse("<!DOCTYPE a [<!ENTITY secret SYSTEM 'file:///C:/Windows/win.ini'>]><a>&secret;</a>"u8.ToArray(), Encoding.UTF8, "XML").Format.Contains("失败"), "XML external entity rejected");
89 Check(ResultParser.Parse(Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("<a>", 80)) + string.Concat(Enumerable.Repeat("</a>", 80))), Encoding.UTF8, "XML").Format.Contains("失败"), "excessively nested XML rejected");
90 Check(ResultParser.Parse("key=a=b\nkey=c"u8.ToArray(), Encoding.UTF8).Content.Contains("a=b"), "key-value duplicate keys and embedded equals retained");
91 Check(ResultParser.Parse([], Encoding.UTF8).Format == "空输出", "empty output handled");
92 Check(ResultParser.Parse(new byte[ResultParser.ParseLimit + 1], Encoding.UTF8).Format.Contains("大体积"), "large parse bounded");
93 Check(ResultParser.Parse("{}"u8.ToArray(), Encoding.UTF8, truncated: true).Format.Contains("截断"), "truncated data not reported as valid JSON");
94
95 var legacy = await RunFixtureAsync("gbk");
96 Check(ResultParser.Parse(legacy.Output, ProcessSession.ResolveEncoding("GB18030 / GBK")).Content.Contains("中文输出"), "GB18030 / GBK decoded");
97 var unicode = await RunFixtureAsync("chunked");
98 Check(Encoding.UTF8.GetString(unicode.Output) == "分块😀输出没有换行", "multibyte Unicode split across writes retained");
99 var nonzero = await RunFixtureAsync("failure");
100 Check(nonzero.Run.ExitCode == 23 && Encoding.UTF8.GetString(nonzero.Error) == "failed", "nonzero exit code and error payload");
101
102 using (var session = new ProcessSession())
103 {
104 var task = session.RunAsync(Options("stdin"));
105 await WaitUntilAsync(() => session.ProcessId > 0);
106 await session.SendInputAsync("交互 输入", true);
107 await session.CloseInputAsync();
108 await task.WaitAsync(TimeSpan.FromSeconds(8));
109 Check(Encoding.UTF8.GetString(session.Output.Snapshot()) == "交互 输入" + Environment.NewLine, "stdin text and EOF round trip");
110 }
111 using (var session = new ProcessSession(65536))
112 {
113 var result = await session.RunAsync(Options("flood")).WaitAsync(TimeSpan.FromSeconds(15));
114 Check(result.ExitCode == 0, "simultaneous stdout/stderr flood finishes without deadlock");
115 Check(session.Output.TotalBytes == 8 * 1024 * 1024 && session.Error.TotalBytes == 8 * 1024 * 1024, "both channels drained fully beyond capture cap");
116 Check(session.Output.Truncated && session.Error.Truncated && session.Output.Snapshot().Length == 65536, "capture limit enforced while continuing to drain");
117 }
118 using (var session = new ProcessSession())
119 {
120 var task = session.RunAsync(Options("sleep"));
121 await WaitUntilAsync(() => session.Output.TotalBytes > 0);
122 int id = session.ProcessId;
123 await session.StopAsync();
124 var result = await task.WaitAsync(TimeSpan.FromSeconds(8));
125 Check(result.Stopped && !IsAlive(id), "stop terminates only owned test process");
126 }
127 using (var session = new ProcessSession())
128 {
129 var task = session.RunAsync(Options("tree"));
130 await WaitUntilAsync(() => session.Output.TotalBytes > 0);
131 int childId = int.Parse(Encoding.UTF8.GetString(session.Output.Snapshot()));
132 await session.StopAsync();
133 await task.WaitAsync(TimeSpan.FromSeconds(8));
134 Check(!IsAlive(childId), "stop terminates owned descendant process");
135 }
136 using (var session = new ProcessSession())
137 {
138 var result = await session.RunAsync(Options("inherit")).WaitAsync(TimeSpan.FromSeconds(8));
139 Check(result.PipesTimedOut, "inherited open pipe bounded after parent exit");
140 }
141 using (var session = new ProcessSession())
142 {
143 bool caught = false;
144 try { await session.RunAsync(Options("text") with { Program = Path.Combine(Artifacts, "not-existing.exe") }); }
145 catch (FileNotFoundException) { caught = true; }
146 Check(caught, "missing executable reported clearly");
147 }
148 using (var session = new ProcessSession())
149 {
150 bool caught = false;
151 try { await session.RunAsync(Options("text") with { Directory = Path.Combine(Artifacts, "not-existing-directory") }); }
152 catch (DirectoryNotFoundException) { caught = true; }
153 Check(caught, "missing working directory reported clearly");
154 }
155 await TestArgumentModesAsync();
156 await TestViewAsync();
157 await ValidateHostAsync(register);
158 if (checkPackage) await ValidatePackageAsync();
159 Console.WriteLine($"ALL {Results.Count} CHECKS PASSED. Artifacts: {Artifacts}");
160 }
161
162 private static bool IsAlive(int id)
163 {
164 try { using var p = Process.GetProcessById(id); return !p.HasExited; }
165 catch (ArgumentException) { return false; }
166 }
167
168 private static async Task TestArgumentModesAsync()
169 {
170 var vm = new MainPageViewModel();
171 Check(vm.UseArgumentList && !vm.IsCommandLineMode && vm.ArgumentItems.Count == 0, "new tool defaults to list mode with zero arguments");
172 Check(vm.CreateLaunchOptions().ArgumentValues.Length == 0, "empty list does not send a spurious empty argument");
173 string[] values = ["--fixture", "json", "--output", @"C:\path with space\", "中文 参数😀", "", "say \"hello\"", " padded ", "a\tb", @"a\\\""b", "a\nb"];
174 foreach (string value in values) { vm.AddArgumentCommand.Execute(null); vm.ArgumentItems[^1].Value = value; }
175 vm.AddArgumentCommand.Execute(null);
176 var removed = vm.ArgumentItems[^1];
177 vm.RemoveArgumentCommand.Execute(removed);
178 Check(vm.ArgumentItems.Select(item => item.Value).SequenceEqual(values) && !vm.RemoveArgumentCommand.CanExecute(removed), "add/remove commands retain argument order and reject stale rows");
179 Check(vm.ArgumentItems.Select(item => item.Label).SequenceEqual(Enumerable.Range(1, values.Length).Select(i => $"参数 {i}")), "argument row numbering updates");
180 vm.Arguments = "--fixture failure";
181 vm.IsCommandLineMode = true;
182 Check(!vm.UseArgumentList && vm.ArgumentItems.Count == values.Length, "switch to raw mode retains list draft");
183 vm.UseArgumentList = true;
184 Check(vm.Arguments == "--fixture failure" && !vm.IsCommandLineMode, "switch to list mode retains raw draft");
185 var snapshot = vm.CreateLaunchOptions();
186 vm.ArgumentItems[2].Value = "changed";
187 Check(snapshot.ArgumentValues[2] == "--output", "launch argument snapshot is independent of subsequent edits");
188 vm.RestoreLaunchOptions(JsonSerializer.Deserialize<LaunchOptions>(JsonSerializer.Serialize(snapshot))!);
189 Check(vm.UseArgumentList && vm.ArgumentItems.Select(x => x.Value).SequenceEqual(values) && vm.Arguments == "--fixture failure", "configuration round trip preserves both drafts and selected mode");
190 vm.ProgramPath = Executable; vm.WorkingDirectory = Artifacts;
191 await vm.RunCommand.ExecuteAsync(null).WaitAsync(TimeSpan.FromSeconds(10));
192 using (var json = JsonDocument.Parse(vm.OutputText))
193 Check(json.RootElement.GetProperty("args").EnumerateArray().Select(x => x.GetString()).SequenceEqual(values.Skip(2)), "real child receives exact list arguments: Unicode, spaces, empty, quotes, slash, tabs and newlines");
194 vm.IsCommandLineMode = true;
195 await vm.RunCommand.ExecuteAsync(null);
196 Check(vm.ExitCodeText.StartsWith("23 "), "raw mode uses command text and ignores inactive list");
197 var old = JsonSerializer.Deserialize<LaunchOptions>("{\"Program\":\"x.exe\",\"Arguments\":\"--name \\\"a b\\\"\",\"Directory\":\"\",\"EncodingName\":\"UTF-8\"}")!;
198 vm.RestoreLaunchOptions(old);
199 Check(vm.IsCommandLineMode && vm.Arguments == "--name \"a b\"", "legacy nonempty command preserved without reinterpretation");
200 vm.RestoreLaunchOptions(old with { Arguments = "" });
201 Check(vm.UseArgumentList, "empty legacy configuration defaults to list mode");
202 await vm.ShutdownAsync();
203 }
204
205 private static async Task TestViewAsync()
206 {
207 var bindingTrace = new BindingTrace();
208 PresentationTraceSources.DataBindingSource.Listeners.Add(bindingTrace);
209 PresentationTraceSources.DataBindingSource.Switch.Level = SourceLevels.Error;
210 var page = new MainPage();
211 var vm = (MainPageViewModel)page.DataContext;
212 var window = new Window { Content = page, Width = 1180, Height = 850, Left = -30000, Top = -30000, ShowInTaskbar = false, WindowStyle = WindowStyle.None };
213 window.Show();
214 vm.ProgramPath = Executable; vm.WorkingDirectory = Artifacts;
215 foreach (string value in new[] { "--fixture", "json", "--output", @"C:\测试目录\包含空格的 文件.txt" })
216 { vm.AddArgumentCommand.Execute(null); vm.ArgumentItems[^1].Value = value; }
217 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
218 Check(((ScrollViewer)page.FindName("ArgumentsScroll")).VerticalOffset > 0, "adding argument scrolls newly added row into view");
219 var listRun = vm.RunCommand.ExecuteAsync(null);
220 Check(!vm.AddArgumentCommand.CanExecute(null) && !vm.RemoveArgumentCommand.CanExecute(vm.ArgumentItems[0]), "list editing commands disabled while process is running");
221 await listRun.WaitAsync(TimeSpan.FromSeconds(10));
222 FindVisual<TabControl>(page)!.SelectedIndex = 2;
223 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
224 SaveView(page, 1180, 850, "program-debugger-arguments.png");
225 window.Width = 928; window.Height = 770;
226 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
227 SaveView(page, 928, 770, "program-debugger-arguments-minimum.png");
228 Check(page.ActualWidth <= 928 && page.ActualHeight <= 770, "list-mode minimum window layout");
229 Check(FindVisual<TextBox>(page, box => box.IsReadOnly && box.Text == vm.ParsedText)!.ActualHeight >= 65, "minimum list-mode layout preserves readable parsed output area");
230 window.Width = 1180; window.Height = 850;
231 vm.UseArgumentList = false;
232 int ticks = 0;
233 TimeSpan maximumGap = TimeSpan.Zero;
234 var clock = Stopwatch.StartNew();
235 TimeSpan last = clock.Elapsed;
236 var heartbeat = new DispatcherTimer(DispatcherPriority.Normal) { Interval = TimeSpan.FromMilliseconds(20) };
237 heartbeat.Tick += (_, _) => { var now = clock.Elapsed; var gap = now - last; if (gap > maximumGap) maximumGap = gap; last = now; ticks++; };
238 heartbeat.Start();
239 vm.ProgramPath = Executable; vm.Arguments = "--fixture slow-json"; vm.WorkingDirectory = Artifacts;
240 var running = vm.RunCommand.ExecuteAsync(null);
241 await WaitUntilAsync(() => vm.OutputText.Contains("message"));
242 Check(vm.IsRunning && !vm.RunCommand.CanExecute(null) && vm.StopCommand.CanExecute(null), "view receives output live and gates run/stop commands");
243 await running.WaitAsync(TimeSpan.FromSeconds(10));
244 heartbeat.Stop();
245 Check(ticks >= 10 && maximumGap.TotalSeconds < 1, $"WPF remains responsive: {ticks} ticks, max gap {maximumGap.TotalMilliseconds:0}ms");
246 Check(vm.FormatText.StartsWith("JSON") && vm.ParsedText.Contains("实时结果"), "actual view command auto-parses completed JSON");
247 Check(vm.ProgressValue == 1 && !vm.IsRunning && vm.RunCommand.CanExecute(null), "completion progress and restart availability");
248 FindVisual<TabControl>(page)!.SelectedIndex = 2;
249 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
250 SaveView(page, 1180, 820, "program-debugger.png");
251 window.Width = 928; window.Height = 700;
252 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
253 SaveView(page, 928, 700, "program-debugger-minimum.png");
254 Check(FindVisual<Button>(page)!.IsVisible && page.ActualWidth <= 928, "minimum size uses actual resized window layout");
255 window.Width = 1180; window.Height = 820;
256 vm.SelectedChannel = vm.Channels[1]; await vm.AnalyzeAsync();
257 Check(vm.FormatText.StartsWith("JSON") && vm.ParsedText.Contains("diagnostic"), "stderr selected and parsed independently");
258 vm.Arguments = "--fixture failure";
259 await vm.RunCommand.ExecuteAsync(null);
260 Check(vm.ExitCodeText.StartsWith("23 ") && vm.ErrorText == "failed", "view restart resets buffers and displays actual exit code");
261 vm.Arguments = "--fixture stdin";
262 running = vm.RunCommand.ExecuteAsync(null);
263 await WaitUntilAsync(() => vm.CanInput);
264 vm.InputText = "从界面发送";
265 await vm.SendInputCommand.ExecuteAsync(null); await vm.CloseInputCommand.ExecuteAsync(null);
266 await running.WaitAsync(TimeSpan.FromSeconds(8));
267 Check(vm.OutputText == "从界面发送" + Environment.NewLine && !vm.CanInput, "view stdin send and close EOF commands");
268 vm.Arguments = "--fixture sleep";
269 running = vm.RunCommand.ExecuteAsync(null);
270 await WaitUntilAsync(() => vm.CanInput);
271 await vm.StopCommand.ExecuteAsync(null); await running.WaitAsync(TimeSpan.FromSeconds(8));
272 Check(vm.ProcessText.Contains("已停止") && !vm.IsRunning, "view stop command completes without freezing");
273 FindVisual<TabControl>(page)!.SelectedIndex = 0;
274 vm.Arguments = "--fixture slow-flood";
275 ticks = 0; maximumGap = TimeSpan.Zero; last = clock.Elapsed; heartbeat.Start();
276 await vm.RunCommand.ExecuteAsync(null).WaitAsync(TimeSpan.FromSeconds(20));
277 heartbeat.Stop();
278 Check(ticks >= 8 && maximumGap.TotalSeconds < 1, $"large live output WPF responsiveness: {ticks} ticks, max gap {maximumGap.TotalMilliseconds:0}ms");
279 Check(vm.Status.Contains("16 MiB") && vm.OutputText.Contains("数据截断") && vm.OutputText.Length < 100000, "view warns about capture truncation and keeps preview bounded");
280 vm.ProgramPath = Path.Combine(Artifacts, "missing.exe");
281 await vm.RunCommand.ExecuteAsync(null);
282 Check(vm.Status.StartsWith("运行失败") && vm.ProgressValue == 0 && vm.RunCommand.CanExecute(null), "view launch failure recovers and progress stays empty");
283 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
284 Check(bindingTrace.Errors.Count == 0, "no WPF binding errors: " + string.Join(" | ", bindingTrace.Errors));
285 await vm.ShutdownAsync(); window.Close();
286 PresentationTraceSources.DataBindingSource.Listeners.Remove(bindingTrace);
287 }
288
289 private static T? FindVisual<T>(DependencyObject root, Func<T, bool>? predicate = null) where T : DependencyObject
290 {
291 if (root is T match && (predicate is null || predicate(match))) return match;
292 for (int i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
293 if (FindVisual<T>(VisualTreeHelper.GetChild(root, i), predicate) is { } child) return child;
294 return null;
295 }
296 private static void SaveView(FrameworkElement page, int width, int height, string file)
297 {
298 page.Measure(new Size(width, height)); page.Arrange(new Rect(0, 0, width, height)); page.UpdateLayout();
299 var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); bitmap.Render(page);
300 var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap));
301 using var output = File.Create(Path.Combine(Artifacts, file)); encoder.Save(output);
302 }
303
304 private static async Task ValidateHostAsync(bool register)
305 {
306 var host = typeof(XFEToolBox.Client.Models.LauncherItem).Assembly;
307 var manifest = JsonSerializer.Deserialize<ToolPackageManifest>(File.ReadAllText(Path.Combine(Workspace, "manifest.json")), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
308 var service = host.GetType("XFEToolBox.Client.Utilities.ToolProjectRunService", true)!;
309 var build = (Task)service.GetMethod("BuildAsync", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, [Workspace, manifest, CancellationToken.None])!;
310 await build.WaitAsync(TimeSpan.FromMinutes(3));
311 var result = build.GetType().GetProperty("Result")!.GetValue(build)!;
312 Check((bool)result.GetType().GetProperty("Success")!.GetValue(result)!, "production runtime host BuildAsync: " + result.GetType().GetProperty("Message")!.GetValue(result));
313 if (register)
314 {
315 var projects = host.GetType("XFEToolBox.Client.Utilities.ToolProjectWorkspaceService", true)!;
316 await (Task)projects.GetMethod("RememberProjectAsync", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, [Workspace])!;
317 Console.WriteLine("Registered project: " + Workspace);
318 }
319 }
320
321 private static async Task ValidatePackageAsync()
322 {
323 var sourceManifest = JsonSerializer.Deserialize<ToolPackageManifest>(File.ReadAllText(Path.Combine(Workspace, "manifest.json")), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
324 string package = Path.Combine(Path.GetDirectoryName(Workspace)!, "Packages", $"{sourceManifest.Id}-{sourceManifest.Version}.xfetool");
325 string extracted = Path.Combine(Artifacts, "package-" + Guid.NewGuid().ToString("N"));
326 var service = typeof(XFEToolBox.Client.Models.LauncherItem).Assembly.GetType("XFEToolBox.Client.Utilities.ToolProjectRunService", true)!;
327 var extract = (Task<ToolPackageManifest>)service.GetMethod("ExtractAndValidatePackageAsync", BindingFlags.NonPublic | BindingFlags.Static)!
328 .Invoke(null, [package, extracted, sourceManifest.Id, sourceManifest.Version, CancellationToken.None])!;
329 var manifest = await extract;
330 var extractedFiles = Directory.GetFiles(extracted, "*", SearchOption.AllDirectories);
331 bool equal = extractedFiles.Length == Directory.GetFiles(Workspace, "*", SearchOption.AllDirectories).Length;
332 foreach (string file in extractedFiles)
333 {
334 string original = Path.Combine(Workspace, Path.GetRelativePath(extracted, file));
335 equal &= File.Exists(original) && File.ReadAllBytes(original).SequenceEqual(File.ReadAllBytes(file));
336 }
337 Check(equal, "production package validation and all extracted files match delivered source");
338 var build = (Task)service.GetMethod("BuildAsync", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, [extracted, manifest, CancellationToken.None])!;
339 await build.WaitAsync(TimeSpan.FromMinutes(3));
340 var result = build.GetType().GetProperty("Result")!.GetValue(build)!;
341 Check((bool)result.GetType().GetProperty("Success")!.GetValue(result)!, "extracted delivery package compiles with actual runtime host");
342 Console.WriteLine("PACKAGE SHA256: " + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(package))));
343 }
344
345 private static void MakeIcon()
346 {
347 var drawing = new DrawingVisual();
348 using (var dc = drawing.RenderOpen())
349 {
350 var gradient = new LinearGradientBrush(Color.FromRgb(93, 187, 252), Color.FromRgb(91, 64, 209), 65);
351 dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(30, 38, 30, 130)), null, new Rect(12, 19, 107, 103), 23, 23);
352 dc.DrawRoundedRectangle(gradient, null, new Rect(8, 8, 108, 104), 22, 22);
353 dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(224, 248, 250, 255)), null, new Rect(18, 19, 88, 25), 9, 9);
354 foreach (int x in new[] { 28, 39, 50 }) dc.DrawEllipse(new SolidColorBrush(Color.FromRgb(115, 135, 233)), null, new Point(x, 31), 3, 3);
355 var white = new Pen(Brushes.White, 7) { StartLineCap = PenLineCap.Round, EndLineCap = PenLineCap.Round, LineJoin = PenLineJoin.Round };
356 dc.DrawLine(white, new Point(30, 60), new Point(45, 74)); dc.DrawLine(white, new Point(45, 74), new Point(30, 88));
357 dc.DrawLine(white, new Point(57, 89), new Point(74, 89));
358 dc.DrawEllipse(new LinearGradientBrush(Color.FromRgb(113, 236, 231), Color.FromRgb(31, 164, 206), 90), new Pen(Brushes.White, 3), new Point(96, 98), 22, 22);
359 var play = new StreamGeometry(); using (var g = play.Open()) { g.BeginFigure(new Point(91, 87), true, true); g.LineTo(new Point(106, 98), true, false); g.LineTo(new Point(91, 109), true, false); }
360 dc.DrawGeometry(Brushes.White, null, play);
361 }
362 var bitmap = new RenderTargetBitmap(128, 128, 96, 96, PixelFormats.Pbgra32); bitmap.Render(drawing);
363 var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap));
364 Directory.CreateDirectory(Path.Combine(Workspace, "Assets"));
365 using var output = File.Create(Path.Combine(Workspace, "Assets", "icon.png")); encoder.Save(output);
366 }
367
368 private sealed class BindingTrace : TraceListener
369 {
370 public List<string> Errors { get; } = [];
371 public override void Write(string? message) { if (!string.IsNullOrWhiteSpace(message)) Errors.Add(message); }
372 public override void WriteLine(string? message) => Write(message);
373 }
374
375 private static async Task<int> FixtureAsync(string[] args)
376 {
377 Console.OutputEncoding = new UTF8Encoding(false); Console.InputEncoding = new UTF8Encoding(false);
378 Stream output = Console.OpenStandardOutput(), error = Console.OpenStandardError();
379 switch (args[0])
380 {
381 case "json":
382 await output.WriteAsync(JsonSerializer.SerializeToUtf8Bytes(new { args = args.Skip(1).ToArray(), cwd = Environment.CurrentDirectory, nested = new { enabled = true, count = 7 } }, new JsonSerializerOptions { WriteIndented = true }));
383 await error.WriteAsync("warning-only"u8.ToArray()); break;
384 case "ndjson": await output.WriteAsync("{\"a\":1}\n[true,null]\n\"中文\"\n"u8.ToArray()); break;
385 case "xml": await output.WriteAsync("<?xml version=\"1.0\"?><root><item enabled=\"true\">中文</item></root>"u8.ToArray()); break;
386 case "csv": await output.WriteAsync("name,value\r\n\"a,\"\"b\"\"\",\"line1\r\nline2\"\r\n"u8.ToArray()); break;
387 case "tsv": await output.WriteAsync("name\tvalue\nfirst\t中文\n"u8.ToArray()); break;
388 case "kv": await output.WriteAsync("name=中文\nvalue=a=b\n"u8.ToArray()); break;
389 case "text": await output.WriteAsync("普通文本 no final newline"u8.ToArray()); break;
390 case "binary": await output.WriteAsync(new byte[] { 0, 1, 2, 0xFF, 0xFE, 0x7F, 65, 0 }); break;
391 case "bom": await output.WriteAsync(Encoding.Unicode.GetPreamble().Concat(Encoding.Unicode.GetBytes("{\"中文\":true}")).ToArray()); break;
392 case "gbk": await output.WriteAsync(ProcessSession.ResolveEncoding("GB18030 / GBK").GetBytes("中文输出")); break;
393 case "chunked": foreach (byte b in "分块😀输出没有换行"u8.ToArray()) { await output.WriteAsync(new[] { b }); await Task.Delay(2); } break;
394 case "failure": await error.WriteAsync("failed"u8.ToArray()); return 23;
395 case "stdin": await output.WriteAsync(Encoding.UTF8.GetBytes(await Console.In.ReadToEndAsync())); break;
396 case "flood":
397 var bytes = Enumerable.Repeat((byte)'x', 65536).ToArray();
398 await Task.WhenAll(Task.Run(async () => { for (int i = 0; i < 128; i++) await output.WriteAsync(bytes); }), Task.Run(async () => { for (int i = 0; i < 128; i++) await error.WriteAsync(bytes); })); break;
399 case "slow-flood":
400 var flood = Enumerable.Repeat((byte)'x', 65536).ToArray();
401 await Task.WhenAll(Task.Run(async () => { for (int i = 0; i < 272; i++) { await output.WriteAsync(flood); await Task.Delay(4); } }), Task.Run(async () => { for (int i = 0; i < 272; i++) { await error.WriteAsync(flood); await Task.Delay(4); } })); break;
402 case "sleep": await output.WriteAsync("ready"u8.ToArray()); await Task.Delay(30000); break;
403 case "tree":
404 using (var child = Process.Start(new ProcessStartInfo(Executable, "--fixture sleep") { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true })!)
405 { await output.WriteAsync(Encoding.UTF8.GetBytes(child.Id.ToString())); await child.WaitForExitAsync(); } break;
406 case "inherit": Process.Start(new ProcessStartInfo(Executable, "--fixture short-sleep") { UseShellExecute = false, CreateNoWindow = true }); break;
407 case "short-sleep": await Task.Delay(4000); break;
408 case "slow-json":
409 await output.WriteAsync("{\n \"message\": \"实时结果\",\n"u8.ToArray());
410 await Task.Delay(1200); await output.WriteAsync(" \"number\": 42,\n \"success\": true,\n \"items\": [1, 2, 3]\n}"u8.ToArray());
411 await error.WriteAsync("{\"diagnostic\":\"测试日志\"}"u8.ToArray()); break;
412 }
413 return 0;
414 }
415 }
Added tests/ProgramDebugger.Validation/ProgramDebugger.Validation.csproj +15 -0
@@ -0,0 +1,15 @@
1 <Project Sdk="Microsoft.NET.Sdk">
2 <PropertyGroup>
3 <OutputType>Exe</OutputType><TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
4 <UseWPF>true</UseWPF><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings>
5 <EnableDefaultCompileItems>false</EnableDefaultCompileItems><EnableDefaultPageItems>false</EnableDefaultPageItems>
6 <StartupObject>ProgramDebugger.Validation.Program</StartupObject>
7 <ToolWorkspace Condition="'$(ToolWorkspace)' == ''">$(LOCALAPPDATA)\XFEToolBox\CrossVersion\EditorWorkspaces\ProgramDebugger</ToolWorkspace>
8 </PropertyGroup>
9 <ItemGroup>
10 <Compile Include="Program.cs"/><Compile Include="$(ToolWorkspace)\Code\**\*.cs" Link="Tool\%(RecursiveDir)%(Filename)%(Extension)"/>
11 <Page Include="$(ToolWorkspace)\Code\**\*.xaml" Link="Tool\%(RecursiveDir)%(Filename)%(Extension)"/>
12 <ProjectReference Include="..\..\XFEToolBox\XFEToolBox.Client.csproj"/>
13 <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2"/>
14 </ItemGroup>
15 </Project>
Added tests/ProgramDebugger.Validation/README.md +15 -0
@@ -0,0 +1,15 @@
1 # 程序调试台验证
2
3 此项目通过链接 EditorWorkspaces 中的实际工具源码编译,使用自身的 `--fixture` 子进程模拟输出;不会运行或结束用户的其他程序。WPF 测试窗口位于屏幕外。
4
5 ```powershell
6 dotnet run --project tests/ProgramDebugger.Validation/ProgramDebugger.Validation.csproj
7 ```
8
9 默认工具路径为 `%LOCALAPPDATA%\XFEToolBox\CrossVersion\EditorWorkspaces\ProgramDebugger`。可以用 MSBuild 的 `-p:ToolWorkspace=...` 改变编译源码路径(宿主验证路径仍需同步更改 Program.cs 的 Workspace)。可加 `-- --register`,仅在所有检查和宿主编译成功后将工具注册到工具工坊的项目历史。
10
11 报告和正常/最小尺寸截图位于输出目录 `test-artifacts`。验证会重建该工具的原生绘制图标 `Assets/icon.png`。测试退出码 0 表示通过,非 0 表示失败。
12
13 对已经生成的 `Packages/xfestudio.program-debugger-<manifest版本号>.xfetool`,添加 `-- --check-package` 可使用生产宿主校验、解包、逐字节对照源文件,并编译实际交付包。不要同时修改源文件而不重新生成对应工具包。
14
15 参数模式测试覆盖默认列表、添加/删除、空格和中文、空字符串、引号与反斜杠、Tab/换行、实际子进程接收结果、切换时保留草稿、配置序列化、兼容旧配置及运行时禁用编辑。
Added tests/TextReplacer.Validation/Program.cs +201 -0
@@ -0,0 +1,201 @@
1 using System.Diagnostics;
2 using System.IO;
3 using System.Reflection;
4 using System.Text;
5 using System.Text.Json;
6 using System.Windows;
7 using System.Windows.Controls;
8 using System.Windows.Media;
9 using System.Windows.Media.Imaging;
10 using System.Windows.Threading;
11 using XFEToolBox.Core.Tools;
12 using XFEToolBox.Tools.BulkTextReplacer;
13
14 namespace TextReplacer.Validation;
15
16 internal static class Program
17 {
18 private static readonly string Workspace = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFEToolBox", "CrossVersion", "EditorWorkspaces", "BulkTextReplacer");
19 private static readonly string Artifacts = Path.Combine(AppContext.BaseDirectory, "test-artifacts");
20 private static readonly List<string> Checks = [];
21 private static int exitCode;
22
23 [STAThread]
24 private static int Main(string[] args)
25 {
26 Directory.CreateDirectory(Artifacts);
27 var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
28 app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/XFEToolBox.WpfCore;component/Resources/Style/ToolThemeResources.xaml") });
29 app.Startup += async (_, _) => {
30 try { await RunAsync(args); }
31 catch (Exception e) { exitCode = 1; Console.Error.WriteLine(e); }
32 finally
33 {
34 File.WriteAllText(Path.Combine(Artifacts, "results.json"), JsonSerializer.Serialize(new { passed = exitCode == 0, at = DateTimeOffset.Now, checks = Checks }, new JsonSerializerOptions { WriteIndented = true }));
35 app.Shutdown();
36 }
37 };
38 app.Run(); return exitCode;
39 }
40 private static void Check(bool condition, string description)
41 {
42 if (!condition) throw new InvalidOperationException("FAIL: " + description);
43 Checks.Add(description); Console.WriteLine("PASS: " + description);
44 }
45 private static async Task ReplaceAsync(MainPageViewModel vm, string source, string search, string replacement, bool sensitive = true)
46 {
47 vm.SourceText = source; vm.InlineSearchText = search; vm.InlineReplacementText = replacement; vm.InlineCaseSensitive = sensitive;
48 await vm.ReplaceTextCommand.ExecuteAsync(null);
49 }
50 private static async Task RunAsync(string[] args)
51 {
52 var vm = new MainPageViewModel();
53 Check(!vm.HasTextResult && vm.TextProgress == 0 && !vm.CopyTextResultCommand.CanExecute(null), "initial result/progress empty and copy disabled");
54 foreach (var test in new (string Source, string Search, string Replacement, bool Sensitive, string Expected, int Count)[] {
55 ("你好 世界,你好!", "你好", "欢迎", true, "欢迎 世界,欢迎!", 2),
56 ("Foo foo FOO", "foo", "bar", true, "Foo bar FOO", 1),
57 ("Foo foo FOO", "foo", "bar", false, "bar bar bar", 3),
58 ("a\r\nb\r\na\r\nb", "a\r\nb", "替换\r\n行", true, "替换\r\n行\r\n替换\r\n行", 2),
59 ("a.b [x] $1", ".", "$&", true, "a$&b [x] $1", 1),
60 ("aaaaa", "aa", "x", true, "xxa", 2),
61 ("😀A😀", "😀", "🚀", true, "🚀A🚀", 2),
62 ("delete delete", "delete", "", true, " ", 2),
63 ("aaa", "a", "", true, "", 3),
64 ("", "a", "b", true, "", 0),
65 ("unchanged", "missing", "new", true, "unchanged", 0)
66 })
67 {
68 await ReplaceAsync(vm, test.Source, test.Search, test.Replacement, test.Sensitive);
69 Check(vm.HasTextResult && vm.ResultText == test.Expected && vm.TextReplacementCount == test.Count && vm.SourceText == test.Source,
70 $"literal replace {Checks.Count}: correct result/count, source preserved");
71 }
72 await ReplaceAsync(vm, "abc", "", "x");
73 Check(!vm.HasTextResult && vm.TextStatus.Contains("不能为空") && vm.TextProgress == 0, "empty search rejected without hanging");
74 await ReplaceAsync(vm, "old old", "old", "new");
75 Check(vm.CopyTextResultCommand.CanExecute(null) && vm.TextProgress == 1, "success enables result actions and completes progress");
76 vm.InlineReplacementText = "changed";
77 Check(!vm.HasTextResult && vm.ResultText == "" && !vm.CopyTextResultCommand.CanExecute(null), "changing rule invalidates stale results");
78 await vm.ReplaceTextCommand.ExecuteAsync(null);
79 vm.UseTextResultCommand.Execute(null);
80 Check(vm.SourceText == "changed changed" && !vm.HasTextResult, "use-result command fills source for next replacement");
81 await ReplaceAsync(vm, new string('a', 100000), "a", new string('b', 50));
82 Check(!vm.HasTextResult && !vm.IsTextBusy && vm.TextStatus.Contains("400 万"), "output expansion limit recovers without allocating huge result");
83 await ReplaceAsync(vm, "recover", "recover", "done");
84 Check(vm.ResultText == "done", "replacement works again after rejected operation");
85
86 await TestFilesAsync();
87 await TestViewAsync();
88 await TestHostAsync(args);
89 Console.WriteLine($"ALL {Checks.Count} CHECKS PASSED. Artifacts: {Artifacts}");
90 }
91
92 private static async Task TestFilesAsync()
93 {
94 string root = Path.Combine(Artifacts, "files-" + Guid.NewGuid().ToString("N"));
95 Directory.CreateDirectory(Path.Combine(root, "child"));
96 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");
97 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") };
98 foreach (var file in encodings) await File.WriteAllTextAsync(file.Path, file.Text, file.Encoding);
99 await File.WriteAllTextAsync(skipped, "old");
100 var original = encodings.ToDictionary(x => x.Path, x => File.ReadAllBytes(x.Path));
101 var vm = new MainPageViewModel { FolderPath = root, Extensions = ".txt", SearchText = "old", ReplacementText = "new", CreateBackups = true, IncludeSubfolders = true };
102 await ReplaceAsync(vm, "old only in textbox", "old", "TEXT");
103 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");
104 Check(vm.SearchText == "old" && vm.ReplacementText == "new", "plain text rules independent of file rules");
105 await vm.ReplaceCommand.ExecuteAsync(null);
106 Check(vm.Status.Contains("修改 3 个文件,共替换 4 处") && vm.Report.Contains("文件批量替换报告"), "existing recursive extension-filtered file replacement works");
107 Check(vm.ResultText == "TEXT only in textbox", "file operation preserves plain text result");
108 foreach (var file in encodings)
109 {
110 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)}");
111 Check(File.ReadAllBytes(file.Path + ".bak").SequenceEqual(original[file.Path]), $"original byte-for-byte backup: {Path.GetFileName(file.Path)}");
112 }
113 Check(File.ReadAllText(skipped) == "old" && !File.Exists(skipped + ".bak"), "excluded extension remains untouched");
114 vm.FolderPath = ""; vm.FilePath = utf8; vm.SearchText = "NEW"; vm.ReplacementText = "single"; vm.CaseSensitive = false; vm.CreateBackups = false;
115 await vm.ReplaceCommand.ExecuteAsync(null);
116 Check(File.ReadAllText(utf8) == "single single" && vm.Status.Contains("修改 1 个文件,共替换 2 处"), "single file and ignore-case mode preserved");
117 }
118
119 private static async Task TestViewAsync()
120 {
121 var trace = new BindingTrace();
122 PresentationTraceSources.DataBindingSource.Listeners.Add(trace); PresentationTraceSources.DataBindingSource.Switch.Level = SourceLevels.Error;
123 var page = new MainPage(); var vm = (MainPageViewModel)page.DataContext;
124 var window = new Window { Content = page, Width = 1060, Height = 730, Left = -30000, Top = -30000, ShowInTaskbar = false, WindowStyle = WindowStyle.None };
125 window.Show();
126 var tabs = Find<TabControl>(page)!;
127 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");
128 await ReplaceAsync(vm, "你好,世界!\n欢迎使用文本替换工具。\n再次问候:你好,世界!", "世界", "XFEToolBox");
129 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
130 SaveView(page, "text-replacer.png");
131 window.Width = 800; window.Height = 580;
132 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
133 SaveView(page, "text-replacer-minimum.png");
134 Check(Find<TextBox>(page, box => box.IsReadOnly && box.Text == vm.ResultText)!.ActualHeight >= 75, "minimum layout keeps result editor readable");
135 string result = vm.ResultText;
136 tabs.SelectedIndex = 1;
137 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
138 Check(vm.ResultText == result && vm.HasTextResult, "switching to file tab retains plain text state");
139 vm.SearchText = "file-only";
140 tabs.SelectedIndex = 0;
141 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
142 Check(vm.InlineSearchText == "世界" && vm.ResultText == result, "switching tabs does not mix replacement fields");
143 int ticks = 0;
144 var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1) }; timer.Tick += (_, _) => ticks++; timer.Start();
145 var task = ReplaceAsync(vm, new string('a', 1000000), "a", "b");
146 Check(!vm.CanEditText && !vm.ReplaceTextCommand.CanExecute(null), "running operation disables duplicate execution/editing");
147 await task; timer.Stop();
148 Check(vm.ResultText.Length == 1000000 && vm.TextReplacementCount == 1000000 && !vm.IsTextBusy, "large text processed asynchronously and UI state restored");
149 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
150 Check(trace.Errors.Count == 0, "no WPF binding errors: " + string.Join(" | ", trace.Errors));
151 window.Close(); PresentationTraceSources.DataBindingSource.Listeners.Remove(trace);
152 }
153 private static T? Find<T>(DependencyObject root, Func<T, bool>? predicate = null) where T : DependencyObject
154 {
155 if (root is T found && (predicate is null || predicate(found))) return found;
156 for (int i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) if (Find(VisualTreeHelper.GetChild(root, i), predicate) is T child) return child;
157 return null;
158 }
159 private static void SaveView(FrameworkElement page, string name)
160 {
161 page.UpdateLayout();
162 var image = new RenderTargetBitmap((int)Math.Ceiling(page.ActualWidth), (int)Math.Ceiling(page.ActualHeight), 96, 96, PixelFormats.Pbgra32); image.Render(page);
163 var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(image));
164 using var file = File.Create(Path.Combine(Artifacts, name)); encoder.Save(file);
165 }
166 private static async Task TestHostAsync(string[] args)
167 {
168 var host = typeof(XFEToolBox.Client.Models.LauncherItem).Assembly;
169 var service = host.GetType("XFEToolBox.Client.Utilities.ToolProjectRunService", true)!;
170 var manifest = JsonSerializer.Deserialize<ToolPackageManifest>(File.ReadAllText(Path.Combine(Workspace, "manifest.json")), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
171 Check(manifest.Name == "文本替换工具" && manifest.Id == "xfestudio.bulk-text-replacer" && manifest.Version == "1.2.0", "renamed manifest keeps existing tool ID and increments version");
172 async Task BuildAsync(string root)
173 {
174 var task = (Task)service.GetMethod("BuildAsync", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, [root, manifest, CancellationToken.None])!;
175 await task.WaitAsync(TimeSpan.FromMinutes(3));
176 var result = task.GetType().GetProperty("Result")!.GetValue(task)!;
177 Check((bool)result.GetType().GetProperty("Success")!.GetValue(result)!, "production host compilation: " + result.GetType().GetProperty("Message")!.GetValue(result));
178 }
179 await BuildAsync(Workspace);
180 if (args.Contains("--check-package"))
181 {
182 string package = Path.Combine(Path.GetDirectoryName(Workspace)!, "Packages", $"{manifest.Id}-{manifest.Version}.xfetool");
183 string extracted = Path.Combine(Artifacts, "package-" + Guid.NewGuid().ToString("N"));
184 await (Task<ToolPackageManifest>)service.GetMethod("ExtractAndValidatePackageAsync", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, [package, extracted, manifest.Id, manifest.Version, CancellationToken.None])!;
185 var files = Directory.GetFiles(extracted, "*", SearchOption.AllDirectories);
186 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");
187 await BuildAsync(extracted);
188 }
189 if (args.Contains("--register"))
190 {
191 await (Task)host.GetType("XFEToolBox.Client.Utilities.ToolProjectWorkspaceService", true)!.GetMethod("RememberProjectAsync", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, [Workspace])!;
192 Console.WriteLine("Project name refreshed: " + manifest.Name);
193 }
194 }
195 private sealed class BindingTrace : TraceListener
196 {
197 public List<string> Errors { get; } = [];
198 public override void Write(string? message) { if (!string.IsNullOrEmpty(message)) Errors.Add(message); }
199 public override void WriteLine(string? message) => Write(message);
200 }
201 }
Added tests/TextReplacer.Validation/README.md +9 -0
@@ -0,0 +1,9 @@
1 # 文本替换工具验证
2
3 ```powershell
4 dotnet run --project tests/TextReplacer.Validation/TextReplacer.Validation.csproj
5 ```
6
7 链接 EditorWorkspaces/BulkTextReplacer 的实际源代码;覆盖普通文本替换、页签顺序与状态隔离、最小窗口、绑定检查、原有文件替换、编码和备份及宿主编译。文件测试只写入输出目录内新建的专用测试目录,不处理用户文件,也不修改用户剪贴板。
8
9 截图和测试报告保存在输出目录 `test-artifacts`。添加 `-- --check-package --register` 可以校验当前 manifest 版本对应的工具包、编译解包源码,并刷新工具工坊项目名称。
Added tests/TextReplacer.Validation/TextReplacer.Validation.csproj +15 -0