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

XFEToolBox

【WPF】XFE工具箱

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

XFEstudio/XFEToolBox

新增验证工程及回归测试主程序

新增 NetworkDoctor.Validation.csproj,配置为 .NET 10 WPF 可执行程序,集成工具源码、主题资源和依赖包。添加 Program.cs,实现自动化回归测试,覆盖分类、修复建议、脚本语法、命令执行、WPF 渲染、包校验等功能,支持多种 runner 和 UI 响应性检测,生成测试产物和报告。补充 README.md,详细说明用途、运行方式、参数、测试内容及限制,便于开发者复现测试流程。

6493bc0
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

3 个文件 +275 -0
Added tests/NetworkDoctor.Validation/NetworkDoctor.Validation.csproj +4 -0
@@ -0,0 +1,4 @@
1 <Project Sdk="Microsoft.NET.Sdk">
2 <PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0-windows10.0.17763.0</TargetFramework><UseWPF>true</UseWPF><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><EnableDefaultCompileItems>false</EnableDefaultCompileItems><EnableDefaultPageItems>false</EnableDefaultPageItems><StartupObject>NetworkDoctor.Validation.Program</StartupObject><ToolWorkspace>$(LOCALAPPDATA)\XFEToolBox\CrossVersion\EditorWorkspaces\NetworkDoctor</ToolWorkspace></PropertyGroup>
3 <ItemGroup><Compile Include="Program.cs"/><Compile Include="$(ToolWorkspace)\Code\**\*.cs" Link="Tool\%(RecursiveDir)%(Filename)%(Extension)"/><Page Include="$(ToolWorkspace)\Code\**\*.xaml" Link="Tool\%(RecursiveDir)%(Filename)%(Extension)"/><ProjectReference Include="..\..\XFEToolBox\XFEToolBox.Client.csproj"/><PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2"/></ItemGroup>
4 </Project>
Added tests/NetworkDoctor.Validation/Program.cs +252 -0
@@ -0,0 +1,252 @@
1 using System.Buffers.Binary;
2 using System.Diagnostics;
3 using System.IO;
4 using System.Net;
5 using System.Net.Sockets;
6 using System.Reflection;
7 using System.Text;
8 using System.Text.Json;
9 using System.Windows;
10 using System.Windows.Controls;
11 using System.Windows.Media;
12 using System.Windows.Media.Imaging;
13 using System.Windows.Threading;
14 using XFEToolBox.Core.Tools;
15 using XFEToolBox.Tools.NetworkDoctor;
16
17 namespace NetworkDoctor.Validation;
18
19 internal static class Program
20 {
21 private static readonly string Workspace = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFEToolBox", "CrossVersion", "EditorWorkspaces", "NetworkDoctor");
22 private static readonly string Artifacts = Path.Combine(AppContext.BaseDirectory, "test-artifacts");
23 private static readonly List<string> Results = [];
24 private static int exitCode;
25 [STAThread]
26 private static int Main(string[] args)
27 {
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 try { await RunAsync(args); }
33 catch (Exception e) { exitCode = 1; Console.Error.WriteLine(e); }
34 finally { File.WriteAllText(Path.Combine(Artifacts, "results.json"), JsonSerializer.Serialize(new { passed = exitCode == 0, time = DateTimeOffset.Now, checks = Results }, new JsonSerializerOptions { WriteIndented = true })); app.Shutdown(); }
35 }; app.Run(); return exitCode;
36 }
37 private static void Assert(bool value, string message) { if (!value) throw new InvalidOperationException("FAIL: " + message); Results.Add(message); Console.WriteLine("PASS: " + message); }
38 private static NetworkSnapshot Fixture() => new() {
39 Adapters = [new("7f975253-371a-4fe6-b12f-31baac0ccafa", "测试网卡(仅模拟)", "Test adapter", "Ethernet", true, 8, true, ["169.254.1.2"], ["127.0.0.1"], ["192.0.2.1"], 1000000000, 0)],
40 System = new() { Services = [new("Dhcp", "Stopped", "Automatic"), new("Dnscache", "Stopped", "Disabled"), new("MpsSvc", "Running", "Automatic")], Addresses = [new("192.0.2.5", "Duplicate", 8)], Routes = [new("0.0.0.0/0", "192.0.2.1", 8, 20), new("0.0.0.0/0", "192.0.2.2", 9, 10)], Interfaces = [new(8,"IPv6",1000,10)], Firewall = [new("Public","False","Allow")], Nrpt = ["企业分流"], Events = ["历史 DNS 超时事件"] },
41 ProxyEnabled = true, ProxyServer = "127.0.0.1:7890", HostsEntries = ["0.0.0.0 www.microsoft.com"], WinsockReadable = true, ActiveProbing = false, TimeWait = 9000
42 };
43 private static async Task RunAsync(string[] args)
44 {
45 MakeIcon();
46 var s = Fixture(); var checks = NetworkScanner.Evaluate(s).ToList();
47 Assert(checks.Single(c => c.Code == "duplicate-ip").Health == Health.Error, "Windows Duplicate address classified as confirmed error");
48 Assert(checks.Single(c => c.Code.StartsWith("ip-")).Health == Health.Warning, "APIPA classified as clue, not blanket total connectivity failure");
49 Assert(checks.Single(c => c.Code == "route").Health == Health.Info, "multiple default routes not declared broken");
50 Assert(checks.Single(c => c.Code == "user-proxy").Health == Health.Info, "configured proxy not declared broken");
51 Assert(checks.Single(c => c.Code == "nrpt").Health == Health.Info, "enterprise DNS rules not declared broken");
52 Assert(checks.Single(c => c.Code == "events").Health == Health.Info, "historical errors not treated as current failures");
53 Assert(checks.Single(c => c.Code == "hosts").Health == Health.Warning, "target hosts override reported without deleting it");
54 Assert(checks.Single(c => c.Code == "mtu-config").Health == Health.Warning, "low IPv6 MTU flagged");
55 Assert(checks.Single(c => c.Code == "firewall").Health == Health.Warning, "disabled firewall reported without disabling security");
56 Assert(checks.Single(c => c.Code == "tcp-count").Health == Health.Warning, "high TIME_WAIT classified as clue");
57 Assert(NetworkScanner.Evaluate(new()).Single(c => c.Code == "duplicate-ip").Health == Health.Unknown, "missing IP data not reported healthy");
58 var v6 = Fixture(); v6.Adapters = [v6.Adapters[0] with { Dhcp = false, Addresses = ["2001:db8::1"], Gateways = ["fe80::1"] }];
59 Assert(NetworkScanner.Evaluate(v6).Single(c => c.Code.StartsWith("ip-")).Health == Health.Info, "IPv6-only network not treated as DHCP failure");
60 foreach (string host in new[] { "https://example.com", "example.com/path", "example.com:443", "a;whoami", "*.example.com" })
61 { bool rejected = false; try { NetworkScanner.ValidateHost(host); } catch { rejected = true; } Assert(rejected, "invalid target rejected: " + host); }
62 Assert(NetworkScanner.ValidateHost("例子.测试").StartsWith("xn--"), "international DNS name converted through IDNA");
63 Assert(!SnapshotCollector.Redact("http://user:secret@host:8080/proxy?token=secret").Contains("secret"), "proxy credentials and query secrets redacted");
64 Assert(SnapshotCollector.Redact("") == "", "absent PAC not misreported as configured");
65 checks.Add(new("dns-system", "DNS", "系统 DNS", Health.Error, "解析失败", "模拟"));
66 var plan = RepairEngine.Plan(s, checks);
67 Assert(plan.Where(p => p.Selected).Select(p => p.Kind).ToHashSet().SetEquals([RepairKind.FlushDns, RepairKind.StartDhcp]), "only evidence-based low-impact repairs recommended");
68 Assert(plan.Where(p => p.Disruptive || p.RequiresRestart).All(p => !p.Selected), "all disruptive/reset repairs opt-in");
69 Assert(plan.All(p => p.Kind != RepairKind.StartDns), "disabled services excluded from recommendations");
70 Assert(RepairEngine.Plan(v6, checks).All(p => p.Kind != RepairKind.RenewDhcp), "static/IPv6-only adapter not offered DHCP renew");
71 var options = new RepairOption[] { new() { Kind=RepairKind.FlushDns, Title="flush", Description="" }, new() { Kind=RepairKind.ResetWinsock, Title="winsock", Description="" } };
72 foreach (var option in plan)
73 {
74 var command = RepairEngine.BuildCommand(option, s);
75 Assert(command.Mutates && Path.IsPathFullyQualified(command.Executable), "allowlisted absolute command: " + option.Kind);
76 }
77 await ValidateScriptsAsync(plan, s);
78 var renew = plan.Single(p => p.Kind == RepairKind.RenewDhcp);
79 string script = Encoding.Unicode.GetString(Convert.FromBase64String(RepairEngine.BuildCommand(renew, s).Arguments[^1]));
80 Assert(script.Contains("DHCPEnabled") && script.Contains(s.Adapters[0].Id) && !script.Contains("/release"), "DHCP renew revalidates exact GUID and avoids release-all");
81 await RejectRepairAsync(options, s, false, true, "repair rejects missing administrator permission");
82 await RejectRepairAsync(options, s, true, false, "repair rejects missing confirmation");
83 var stale = Fixture(); stale.CapturedAt = DateTimeOffset.Now.AddHours(-1);
84 await RejectRepairAsync(options, stale, true, true, "stale snapshot rejected before mutation");
85 await RejectRepairAsync([new() { Kind = RepairKind.RestartAdapter, AdapterId="';evil",Title="bad",Description="" }], s, true, true, "invalid adapter identifier rejected before mutation");
86 await RejectRepairAsync([new() { Kind=(RepairKind)999,Title="bad",Description="" }],s,true,true,"unknown repair rejected");
87 var fake = new FakeRunner();
88 string repairRoot = Path.Combine(Artifacts, "repairs");
89 fake.OnRun = command => { if (command.Mutates) Assert(Directory.GetFiles(repairRoot, "before.json", SearchOption.AllDirectories).Length > 0, "backup exists before mutation"); return new(0,"simulated success",""); };
90 var repair = await RepairEngine.ExecuteAsync(options,s,fake,repairRoot,true,true,new InlineProgress<string>(_=>{}),default);
91 Assert(repair.Steps.All(x => x.CommandSucceeded) && repair.Steps[1].RestartRequired && File.Exists(Path.Combine(repair.BackupDirectory,"result.json")), "repair success journal and restart requirement retained");
92 fake = new FakeRunner { OnRun = _ => new(5,"","simulated access denied") };
93 var failed = await RepairEngine.ExecuteAsync([options[0]],s,fake,repairRoot,true,true,new InlineProgress<string>(_=>{}),default);
94 Assert(!failed.Steps[0].CommandSucceeded, "nonzero exit code never reported as repaired");
95 fake = new FakeRunner { OnRun = _ => new(-1,"","timeout",true) };
96 var timeout = await RepairEngine.ExecuteAsync([options[0]],s,fake,repairRoot,true,true,new InlineProgress<string>(_=>{}),default);
97 Assert(!timeout.Steps[0].CommandSucceeded, "timeout result not treated as success");
98 using (var cts = new CancellationTokenSource())
99 {
100 fake = new FakeRunner { OnRun = _ => { cts.Cancel(); return new(0,"first step done",""); } };
101 var cancelled = await RepairEngine.ExecuteAsync(options,s,fake,repairRoot,true,true,new InlineProgress<string>(_=>{}),cts.Token);
102 Assert(cancelled.Cancelled && fake.Commands.Count == 1 && cancelled.Steps[0].CommandSucceeded, "cancellation finishes current step but skips later steps");
103 }
104 fake = new FakeRunner { OnRun = command => new(1,"","backup unavailable") };
105 bool backupRejected=false;
106 try { await RepairEngine.ExecuteAsync([plan.Single(p=>p.Kind==RepairKind.ResetWinHttp)],s,fake,repairRoot,true,true,new InlineProgress<string>(_=>{}),default); } catch(IOException) {backupRejected=true;}
107 Assert(backupRejected && fake.Commands.All(c=>!c.Mutates), "WinHTTP backup failure prevents mutation");
108 await ProbeTestsAsync();
109 await CommandTestsAsync();
110 await ViewTestsAsync(s,checks,plan);
111 if(args.Contains("--live")) await LiveAsync();
112 await HostAsync(args);
113 Console.WriteLine($"ALL {Results.Count} CHECKS PASSED. Artifacts: {Artifacts}");
114 }
115 private static async Task RejectRepairAsync(RepairOption[] options,NetworkSnapshot s,bool admin,bool confirmed,string message)
116 {
117 var fake=new FakeRunner(); bool rejected=false;
118 try {await RepairEngine.ExecuteAsync(options,s,fake,Path.Combine(Artifacts,"reject"),admin,confirmed,new InlineProgress<string>(_=>{}),default);} catch {rejected=true;}
119 Assert(rejected && fake.Commands.Count==0,message);
120 }
121 private static async Task ValidateScriptsAsync(List<RepairOption> plan, NetworkSnapshot snapshot)
122 {
123 var commands = plan.Select(p => RepairEngine.BuildCommand(p, snapshot)).Append(WindowsCommands.PowerShell(SnapshotCollector.SystemScript));
124 foreach (var command in commands.Where(c => c.Executable.EndsWith("powershell.exe", StringComparison.OrdinalIgnoreCase)))
125 {
126 string code = "$src=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('" + command.Arguments[^1] + "')); $tokens=$null; $errors=$null; [void][System.Management.Automation.Language.Parser]::ParseInput($src,[ref]$tokens,[ref]$errors); if($errors.Count -gt 0){throw ($errors | Out-String)}; 'Parsed without executing the script'";
127 var result = await new WindowsCommands().RunAsync(WindowsCommands.PowerShell(code), default);
128 Assert(result.Success, "Windows PowerShell production script syntax: " + result.Text);
129 }
130 }
131 private static async Task ProbeTestsAsync()
132 {
133 byte[] query=NetworkProbes.DnsQuery("example.com",123), reply=Response(query);
134 Assert(NetworkProbes.ValidateDns(query,reply).Answers==1,"valid compressed DNS answer parsed");
135 foreach(var invalid in new[]{reply[..10],reply[..^1],reply.Select((b,i)=>(byte)(i==0?b^1:b)).ToArray()})
136 {bool rejected=false;try{NetworkProbes.ValidateDns(query,invalid);}catch(InvalidDataException){rejected=true;}Assert(rejected,"malformed/mismatched DNS packet rejected");}
137 using var udp=new UdpClient(new IPEndPoint(IPAddress.Loopback,0)); int port=((IPEndPoint)udp.Client.LocalEndPoint!).Port;
138 var udpTask=Task.Run(async()=>{var request=await udp.ReceiveAsync();await udp.SendAsync(Response(request.Buffer),request.RemoteEndPoint);});
139 var dns=await NetworkProbes.DnsServerAsync(IPAddress.Loopback,"example.com",default,port);await udpTask;
140 Assert(dns.Health==Health.Pass,"real UDP DNS exchange on loopback");
141 var fallbackServer = new TcpListener(IPAddress.Loopback, 0); fallbackServer.Start();
142 int fallbackPort = ((IPEndPoint)fallbackServer.LocalEndpoint).Port;
143 using var fallbackUdp = new UdpClient(new IPEndPoint(IPAddress.Loopback, fallbackPort));
144 using var fallbackDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(6));
145 var fallbackServing = Task.Run(async () => {
146 var request = await fallbackUdp.ReceiveAsync(fallbackDeadline.Token);
147 byte[] tc = request.Buffer.ToArray(); tc[2] = 0x83; tc[3] = 0x80;
148 await fallbackUdp.SendAsync(tc, request.RemoteEndPoint, fallbackDeadline.Token);
149 using var socket = await fallbackServer.AcceptTcpClientAsync(fallbackDeadline.Token);
150 using var stream = socket.GetStream(); byte[] length = new byte[2];
151 await stream.ReadExactlyAsync(length, fallbackDeadline.Token);
152 byte[] tcpQuery = new byte[BinaryPrimitives.ReadUInt16BigEndian(length)];
153 await stream.ReadExactlyAsync(tcpQuery, fallbackDeadline.Token);
154 byte[] answer = Response(tcpQuery); BinaryPrimitives.WriteUInt16BigEndian(length, (ushort)answer.Length);
155 await stream.WriteAsync(length, fallbackDeadline.Token); await stream.WriteAsync(answer.AsMemory(0, 7), fallbackDeadline.Token);
156 await Task.Delay(20, fallbackDeadline.Token); await stream.WriteAsync(answer.AsMemory(7), fallbackDeadline.Token);
157 });
158 var fallbackResult = await NetworkProbes.DnsServerAsync(IPAddress.Loopback, "example.com", fallbackDeadline.Token, fallbackPort);
159 await fallbackServing; fallbackServer.Stop(); Assert(fallbackResult.Health == Health.Pass, "truncated UDP DNS falls back to length-framed TCP with partial reads");
160 var tcp=new TcpListener(IPAddress.Loopback,0);tcp.Start();int tcpPort=((IPEndPoint)tcp.LocalEndpoint).Port;
161 var accepted=tcp.AcceptTcpClientAsync();var success=await NetworkProbes.TcpAsync("test",IPAddress.Loopback,tcpPort,default);using(var socket=await accepted){}
162 tcp.Stop();Assert(success.Health==Health.Pass,"real TCP open endpoint succeeds");
163 var refused=await NetworkProbes.TcpAsync("test",IPAddress.Loopback,tcpPort,default);Assert(refused.Health==Health.Warning,"real closed TCP endpoint reported without claiming total outage");
164 foreach(var item in new[]{(Status:200,Body:"Microsoft Connect Test",Portal:true,Expected:Health.Pass),(Status:302,Body:"login",Portal:true,Expected:Health.Warning),(Status:403,Body:"forbidden",Portal:false,Expected:Health.Pass),(Status:407,Body:"proxy auth",Portal:false,Expected:Health.Warning)})
165 {
166 var server=new TcpListener(IPAddress.Loopback,0);server.Start();int p=((IPEndPoint)server.LocalEndpoint).Port;
167 var serving=Task.Run(async()=>{using var socket=await server.AcceptTcpClientAsync();using var stream=socket.GetStream();byte[] buffer=new byte[4096];int received=await stream.ReadAsync(buffer);if(received==0)throw new IOException("Empty HTTP fixture request");byte[] body=Encoding.UTF8.GetBytes(item.Body);await stream.WriteAsync(Encoding.ASCII.GetBytes($"HTTP/1.1 {item.Status} Test\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n"));await stream.WriteAsync(body);});
168 var check=await NetworkProbes.HttpAsync(new Uri($"http://127.0.0.1:{p}/"),false,item.Portal,default);await serving;server.Stop();
169 Assert(check.Health==item.Expected,"HTTP/NCSI classification: "+item.Status+" portal="+item.Portal);
170 }
171 using var cancellation=new CancellationTokenSource();cancellation.Cancel();bool cancelled=false;
172 try{await NetworkProbes.TcpAsync("cancel",IPAddress.Loopback,9,cancellation.Token);}catch(OperationCanceledException){cancelled=true;}
173 Assert(cancelled,"probe cancellation propagates rather than false fault result");
174 }
175 private static byte[] Response(byte[] query)
176 {
177 byte[] reply=new byte[query.Length+16];query.CopyTo(reply,0);reply[2]=0x81;reply[3]=0x80;reply[7]=1;
178 new byte[]{0xc0,12,0,1,0,1,0,0,0,60,0,4,192,0,2,1}.CopyTo(reply,query.Length);return reply;
179 }
180 private static async Task CommandTestsAsync()
181 {
182 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); var oem = Encoding.GetEncoding(936);
183 const string chinese = "当前 WinHTTP 代理服务器设置:直接访问";
184 Assert(WindowsCommands.DecodeOutput(Encoding.UTF8.GetBytes(chinese), oem) == chinese, "native UTF-8 Chinese output decoded");
185 Assert(WindowsCommands.DecodeOutput(oem.GetBytes(chinese), oem) == chinese, "native OEM Chinese output decoded");
186 Assert(WindowsCommands.DecodeOutput([255,254,..Encoding.Unicode.GetBytes(chinese)], oem) == chinese, "UTF-16 BOM output decoded");
187 var real = new WindowsCommands();
188 var unicode = await real.RunAsync(WindowsCommands.PowerShell("[Console]::WriteLine('网络测试'); [Console]::Error.WriteLine('错误流测试')"), default);
189 Assert(unicode.Success && unicode.Output.Contains("网络测试") && unicode.Error.Contains("错误流测试"), "real command captures separate Unicode stdout and stderr");
190 var nonzero = await real.RunAsync(WindowsCommands.PowerShell("exit 7"), default);
191 Assert(nonzero.ExitCode == 7 && !nonzero.Success, "real nonzero child exit preserved");
192 var watch = Stopwatch.StartNew();
193 var timed = await real.RunAsync(WindowsCommands.PowerShell("Start-Sleep -Seconds 10", timeout:1), default);
194 Assert(timed.TimedOut && !timed.Success && watch.Elapsed < TimeSpan.FromSeconds(5), "real helper timeout bounded and own child stopped");
195 using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); bool cancelled = false;
196 try { await real.RunAsync(WindowsCommands.PowerShell("Start-Sleep -Seconds 10"), cancel.Token); } catch (OperationCanceledException) { cancelled = true; }
197 Assert(cancelled, "real command cancellation propagates");
198 var big = await real.RunAsync(WindowsCommands.PowerShell("[Console]::Write([string]::new([char]'x',1100000)); [Console]::Error.Write('done')"), default);
199 Assert(big.Success && big.Output.Length < 1050000 && big.Output.Contains("输出已截断") && big.Error == "done", "oversized output bounded while both pipes drain");
200 var unreadable = await SnapshotCollector.CaptureAsync(new FakeRunner { OnRun = _ => throw new IOException("simulated helper unavailable") }, default);
201 Assert(unreadable.ReadErrors.Length >= 2 && !unreadable.WinsockReadable && unreadable.Adapters.Length > 0, "unavailable command preserves partial read-only snapshot");
202 }
203 private static async Task ViewTestsAsync(NetworkSnapshot s,List<Check> checks,List<RepairOption> plan)
204 {
205 var trace=new BindingTrace();PresentationTraceSources.DataBindingSource.Listeners.Add(trace);PresentationTraceSources.DataBindingSource.Switch.Level=SourceLevels.Error;
206 var page=new MainPage();var vm=(MainPageViewModel)page.DataContext;
207 foreach(var check in checks)vm.Checks.Add(check);foreach(var option in plan)vm.Repairs.Add(option);vm.SelectedCheck=checks.Single(c=>c.Code=="duplicate-ip");vm.ProgressValue=1;vm.Phase="测试数据预览";vm.Status="当前展示模拟故障,未修改网络。";vm.Report=MainPageViewModel.BuildReport(s,checks);
208 var window=new Window{Content=page,Width=1180,Height=780,Left=-30000,Top=-30000,WindowStyle=WindowStyle.None,ShowInTaskbar=false};window.Show();
209 await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);SaveView(page,"network-doctor.png");
210 vm.SelectedTab=1;await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);SaveView(page,"network-doctor-repairs.png");
211 window.Width=920;window.Height=650;await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);SaveView(page,"network-doctor-minimum.png");
212 Assert(page.ActualWidth<=920,"minimum layout rendered");
213 vm.IsRepairing=true;var closing=new System.ComponentModel.CancelEventArgs();vm.OnClosing(closing);Assert(closing.Cancel,"closing blocked during active repair to avoid half-finished adapter restart");vm.IsRepairing=false;
214 vm.SelectedTab=2;await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);Assert(trace.Errors.Count==0,"no WPF binding errors: "+string.Join(" | ",trace.Errors));
215 window.Close();PresentationTraceSources.DataBindingSource.Listeners.Remove(trace);
216 }
217 private static async Task LiveAsync()
218 {
219 var runner=new ReadOnlyRunner(); var vm=new MainPageViewModel(runner); int ticks=0;
220 var timer=new DispatcherTimer { Interval=TimeSpan.FromMilliseconds(20) }; timer.Tick+=(_,_)=>ticks++; timer.Start();
221 vm.Checks.CollectionChanged+=(_,e)=>{if(e.NewItems is not null)foreach(Check c in e.NewItems)Console.WriteLine("LIVE: "+c.Title+" => "+c.State+" / "+c.Summary);};
222 await vm.ScanCommand.ExecuteAsync(null).WaitAsync(TimeSpan.FromSeconds(80)); timer.Stop();
223 Assert(vm.Checks.Count>15 && vm.ProgressValue==1 && !vm.IsBusy && vm.Report.Contains("配置快照"),"real machine read-only scan returns layered results");
224 Assert(ticks>10, "UI dispatcher remains responsive during real scan");
225 Assert(vm.Repairs.Count>0 && vm.Repairs.Where(r=>r.Disruptive).All(r=>!r.Selected), "real scan populates guarded repair choices");
226 Assert(runner.Commands.All(c=>!c.Mutates),"live validation executed no repair or network configuration changes");
227 File.WriteAllText(Path.Combine(Artifacts,"live-report.txt"),vm.Report);
228 }
229 private static async Task HostAsync(string[] args)
230 {
231 var assembly=typeof(XFEToolBox.Client.Models.LauncherItem).Assembly;var service=assembly.GetType("XFEToolBox.Client.Utilities.ToolProjectRunService",true)!;
232 var manifest=JsonSerializer.Deserialize<ToolPackageManifest>(File.ReadAllText(Path.Combine(Workspace,"manifest.json")),new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
233 async Task Build(string root){var task=(Task)service.GetMethod("BuildAsync",BindingFlags.Public|BindingFlags.Static)!.Invoke(null,[root,manifest,CancellationToken.None])!;await task.WaitAsync(TimeSpan.FromMinutes(3));var result=task.GetType().GetProperty("Result")!.GetValue(task)!;Assert((bool)result.GetType().GetProperty("Success")!.GetValue(result)!,"production host compilation: "+result.GetType().GetProperty("Message")!.GetValue(result));}
234 await Build(Workspace);
235 if(args.Contains("--check-package")){string package=Path.Combine(Path.GetDirectoryName(Workspace)!,"Packages",$"{manifest.Id}-{manifest.Version}.xfetool"),extracted=Path.Combine(Artifacts,"package-"+Guid.NewGuid().ToString("N"));await (Task<ToolPackageManifest>)service.GetMethod("ExtractAndValidatePackageAsync",BindingFlags.NonPublic|BindingFlags.Static)!.Invoke(null,[package,extracted,manifest.Id,manifest.Version,CancellationToken.None])!;var files=Directory.GetFiles(extracted,"*",SearchOption.AllDirectories);Assert(files.Length==Directory.GetFiles(Workspace,"*",SearchOption.AllDirectories).Length&&files.All(f=>File.ReadAllBytes(f).SequenceEqual(File.ReadAllBytes(Path.Combine(Workspace,Path.GetRelativePath(extracted,f))))),"package matches tested sources");await Build(extracted);}
236 if(args.Contains("--register"))await (Task)assembly.GetType("XFEToolBox.Client.Utilities.ToolProjectWorkspaceService",true)!.GetMethod("RememberProjectAsync",BindingFlags.Public|BindingFlags.Static)!.Invoke(null,[Workspace])!;
237 }
238 private static void SaveView(FrameworkElement page,string name){page.UpdateLayout();var image=new RenderTargetBitmap((int)page.ActualWidth,(int)page.ActualHeight,96,96,PixelFormats.Pbgra32);image.Render(page);var encoder=new PngBitmapEncoder();encoder.Frames.Add(BitmapFrame.Create(image));using var file=File.Create(Path.Combine(Artifacts,name));encoder.Save(file);}
239 private static void MakeIcon()
240 {
241 var v=new DrawingVisual();using(var d=v.RenderOpen()){
242 d.DrawRoundedRectangle(new LinearGradientBrush(Color.FromRgb(42,194,235),Color.FromRgb(76,70,197),65),null,new Rect(9,9,106,106),24,24);
243 var pen=new Pen(new SolidColorBrush(Color.FromArgb(235,245,252,255)),4);d.DrawEllipse(null,pen,new Point(62,60),36,36);d.DrawEllipse(null,pen,new Point(62,60),17,36);d.DrawLine(pen,new Point(26,60),new Point(98,60));d.DrawLine(pen,new Point(32,43),new Point(92,43));d.DrawLine(pen,new Point(32,77),new Point(92,77));
244 d.DrawEllipse(new LinearGradientBrush(Color.FromRgb(129,237,165),Color.FromRgb(21,160,132),90),new Pen(Brushes.White,3),new Point(97,98),23,23);
245 var check=new Pen(Brushes.White,6){StartLineCap=PenLineCap.Round,EndLineCap=PenLineCap.Round};d.DrawLine(check,new Point(85,99),new Point(94,107));d.DrawLine(check,new Point(94,107),new Point(109,90));}
246 var bitmap=new RenderTargetBitmap(128,128,96,96,PixelFormats.Pbgra32);bitmap.Render(v);var encoder=new PngBitmapEncoder();encoder.Frames.Add(BitmapFrame.Create(bitmap));Directory.CreateDirectory(Path.Combine(Workspace,"Assets"));using var file=File.Create(Path.Combine(Workspace,"Assets","icon.png"));encoder.Save(file);
247 }
248 private sealed class FakeRunner:ICommandRunner{public List<CommandSpec> Commands{get;}=[];public Func<CommandSpec,CommandResult> OnRun{get;set;}=_=>new(0,"ok","");public Task<CommandResult> RunAsync(CommandSpec c,CancellationToken t){Commands.Add(c);return Task.FromResult(OnRun(c));}}
249 private sealed class ReadOnlyRunner:ICommandRunner{private readonly WindowsCommands real=new();public List<CommandSpec> Commands{get;}=[];public Task<CommandResult> RunAsync(CommandSpec c,CancellationToken t){if(c.Mutates)throw new InvalidOperationException("Live tests forbid mutations");Commands.Add(c);return real.RunAsync(c,t);}}
250 private sealed class InlineProgress<T>(Action<T> action):IProgress<T>{public void Report(T value)=>action(value);}
251 private sealed class BindingTrace:TraceListener{public List<string> Errors{get;}=[];public override void Write(string? text){if(!string.IsNullOrEmpty(text))Errors.Add(text);}public override void WriteLine(string? text)=>Write(text);}
252 }
Added tests/NetworkDoctor.Validation/README.md +19 -0
@@ -0,0 +1,19 @@
1 # NetworkDoctor validation
2
3 Windows / .NET 10 WPF regression harness. Links actual tool sources from `%LOCALAPPDATA%\XFEToolBox\CrossVersion\EditorWorkspaces\NetworkDoctor`; override `ToolWorkspace` via MSBuild if needed. Uses the real host theme, package validator, and compiler.
4
5 ```powershell
6 dotnet run --project tests/NetworkDoctor.Validation/NetworkDoctor.Validation.csproj -- --live
7 dotnet run --project tests/NetworkDoctor.Validation/NetworkDoctor.Validation.csproj -- --check-package --register
8 ```
9
10 - Normal run: classification/repair guard tests, simulated mutation runner, loopback DNS (UDP and truncated-response TCP fallback), HTTP and TCP fixtures, real read-only child processes, output encoding/size/cancellation/timeout tests, PowerShell AST parsing of production scripts (no repair execution), WPF binding and responsive layout screenshots, host compilation.
11 - `--live`: runs the production ViewModel scan on the current machine, checks UI dispatcher responsiveness, streamed results and opt-in repair choices. A guard rejects any command marked `Mutates`. No actual network configuration is modified.
12 - `--check-package`: validates and extracts `Packages/xfestudio.network-doctor-1.0.0.xfetool`, byte-compares every file with the workspace, compiles the extracted package using the production host.
13 - `--register`: adds the engineering directory to the tool editor's recent project list.
14
15 Artifacts are under `bin/Debug/net10.0-windows10.0.17763.0/test-artifacts`: `results.json`, three WPF screenshots, simulated repair journals, and optionally `live-report.txt` (contains private network configuration; do not commit).
16
17 Tests create child PowerShell processes that only print text, exit, parse strings, or sleep. Timeout/cancellation stops only those children. Repairs use a fake executor; no flush, renew, adapter restart, registry change or Winsock/WinHTTP reset is executed. WPF windows used for off-screen rendering do not appear in the taskbar.
18
19 Live administrator/UAC interactions and real disruptive repairs on a disposable network are not automated by this harness. Command success is not asserted as proof of restored connectivity.