返回提交历史
Added
XFEToolBox.Client.Wpf.Test/HomeDashboardRefreshTests.cs
+124
-0
Modified
XFEToolBox/Utilities/Helpers/BilibiliHelper.cs
+1
-1
Modified
XFEToolBox/Utilities/LauncherService.cs
+38
-14
Modified
XFEToolBox/ViewModel/Pages/LauncherItemViewModel.cs
+20
-1
Modified
XFEToolBox/ViewModel/Pages/MainPageViewModel.cs
+105
-39
Added
tests/ProgramDebugger.Validation/Program.cs
+415
-0
Added
tests/ProgramDebugger.Validation/ProgramDebugger.Validation.csproj
+15
-0
Added
tests/ProgramDebugger.Validation/README.md
+15
-0
Added
tests/TextReplacer.Validation/Program.cs
+201
-0
Added
tests/TextReplacer.Validation/README.md
+9
-0
Added
tests/TextReplacer.Validation/TextReplacer.Validation.csproj
+15
-0
XFEstudio/XFEToolBox
新增端到端验证测试项目及主页刷新优化
增加 ProgramDebugger.Validation 和 TextReplacer.Validation 两个端到端验证测试项目,覆盖自动化功能、界面、宿主编译和包校验等场景,支持参数传递、输出解析、编码、UI 绑定、窗口布局、文件操作等多项验证,自动生成测试报告和截图。优化主页卡片和活动列表刷新逻辑,提升性能和 UI 响应性,避免集合重建和图标重复加载。新增 HomeDashboardRefreshTests,确保主页刷新行为正确。更新 README.md,补充测试项目说明和使用方法。
22f191d
代码差异
11 个文件
+958
-55
@@ -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
}
@@ -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)
@@ -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()
@@ -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
@@ -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
@@ -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
}
@@ -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>
@@ -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/换行、实际子进程接收结果、切换时保留草稿、配置序列化、兼容旧配置及运行时禁用编辑。
@@ -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
}
@@ -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 版本对应的工具包、编译解包源码,并刷新工具工坊项目名称。