using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using XFEToolBox.Client.Core.Search; using XFEToolBox.Client.Utilities; using XFEToolBox.Client.ViewModel.Pages; namespace XFEToolBox.Client.Wpf.Test; public static class ShellFileIconTests { [Test] public static async Task FileIconsCoalesceAssociationRequestsAndOnlyUseDedicatedStaWorkers() { var callerThread = Environment.CurrentManagedThreadId; var calls = 0; using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); using var service = new ShellFileIconService(1, 8, 4, (path, directory, attributes) => { Ensure(Environment.CurrentManagedThreadId != callerThread && !Thread.CurrentThread.IsThreadPoolThread && Thread.CurrentThread.GetApartmentState() == ApartmentState.STA, "Shell 图标加载占用了调用者/线程池,或未使用专用 STA 线程。"); Ensure(path == "file.txt" && !directory && attributes, "普通文档没有使用属性查询的扩展名关联图标。"); Interlocked.Increment(ref calls); entered.Set(); release.Wait(TimeSpan.FromSeconds(5)); return TestImage(); }); var first = service.GetIconAsync(@"C:\first.txt", false); Ensure(entered.Wait(TimeSpan.FromSeconds(3)), "图标工作线程未开始。"); var repeated = Enumerable.Range(0, 100).Select(index => service.GetIconAsync($@"C:\folder{index}\second.TXT", false)).ToArray(); Ensure(service.Counts.Pending == 1, "同类型文件重复排队获取 Shell 图标。"); release.Set(); var all = await Task.WhenAll(repeated.Prepend(first)); Ensure(calls == 1 && all.All(image => image is { IsFrozen: true } && ReferenceEquals(image, all[0])), "合并请求没有共用冻结后的跨线程图标。"); Ensure(ReferenceEquals(await service.GetIconAsync(@"D:\third.txt", false), all[0]) && calls == 1, "扩展名关联图标没有命中共享缓存。"); } [Test] public static async Task FileIconQueueAndCacheStayBoundedAndCancelledRowsDoNotCallShell() { var calls = 0; var order = new List(); using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); using var service = new ShellFileIconService(1, 8, 4, (path, _, _) => { lock (order) order.Add(path); if (Interlocked.Increment(ref calls) == 1) { entered.Set(); release.Wait(TimeSpan.FromSeconds(5)); } return TestImage(); }); var active = service.GetIconAsync(@"C:\active.exe", false); Ensure(entered.Wait(TimeSpan.FromSeconds(3)), "图标工作线程未开始。"); var obsolete = new List>(); for (var index = 0; index < 1000; index++) obsolete.Add(service.GetIconAsync($@"C:\old{index}.exe", false)); Ensure(service.Counts.Queued == 8 && service.Counts.Pending <= 9, "快速输入导致图标请求无限堆积。"); Ensure(obsolete.Take(992).All(task => task.IsCompletedSuccessfully && task.Result is null), "超出队列容量的旧图标请求没有及时释放。"); using var cancellation = new CancellationTokenSource(); var cancelled = service.GetIconAsync(@"C:\cancelled.ico", false, cancellation.Token); cancellation.Cancel(); Ensure(await cancelled is null, "取消图标请求发生异常或继续等待 Shell。"); var latest = service.GetIconAsync(@"C:\latest.lnk", false); release.Set(); Ensure(await active is not null && await latest is not null, "最新图标请求没有完成。"); await Task.WhenAll(obsolete); Ensure(order.Count >= 2 && order[1] == @"C:\latest.lnk", "最新可见请求没有优先于积压的旧图标请求执行。"); Ensure(calls <= 9 && service.Counts.Cached <= 4, "图标 Shell 调用数量或缓存超出上限。"); var cached = await service.GetIconAsync(@"C:\latest.lnk", false); Ensure(cached is { IsFrozen: true }, "路径专用图标没有进入冻结缓存。"); } [Test] public static void DroppedIconRequestCanRetryWhenTheSameResultIsRealizedAgain() { RunSta(() => { using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); using var service = new ShellFileIconService(1, 1, 4, (path, _, _) => { if (path == @"C:\blocking.exe") { entered.Set(); release.Wait(TimeSpan.FromSeconds(5)); } return TestImage(); }); var blocking = service.GetIconAsync(@"C:\blocking.exe", false); Ensure(entered.Wait(TimeSpan.FromSeconds(3)), "图标工作线程未开始。"); using var row = new FileSearchResultViewModel(new FileSearchMatch(new FileSearchEntry(@"C:\retry.exe", false), 0), service); Ensure(row.IconSource is null && service.Counts.Queued == 1, "首次图标请求未排队。"); var newer = service.GetIconAsync(@"C:\newer.exe", false); // Bounded queue evicts the row's first request. var watch = Stopwatch.StartNew(); while (!newer.IsCompleted && watch.Elapsed < TimeSpan.FromSeconds(3)) { Pump(); _ = row.IconSource; // A later realization/binding re-reads the same VM's icon property. Thread.Sleep(1); } Ensure(newer.IsCompletedSuccessfully && newer.Result is null && row.IconSource is null && service.Counts.Queued == 1, "被队列丢弃的请求标记未重置,同一条目重新实现绑定后没有重试图标请求。"); release.Set(); watch.Restart(); while (!row.HasIcon && watch.Elapsed < TimeSpan.FromSeconds(3)) { Pump(); Thread.Sleep(1); } Ensure(row.IconSource is { IsFrozen: true }, "同一条目被丢弃的图标请求重试后仍永久显示 glyph。"); Ensure(blocking.IsCompletedSuccessfully && newer.IsCompletedSuccessfully && newer.Result is null, "重新绑定后的最新请求没有替换旧排队请求。"); }); } [Test] public static void SearchResultIconsAreLazyAndNotifyTheUiWithoutBlockingIt() { RunSta(() => { var calls = 0; using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); using var service = new ShellFileIconService(1, 8, 4, (_, _, _) => { Interlocked.Increment(ref calls); entered.Set(); release.Wait(TimeSpan.FromSeconds(5)); return TestImage(); }); var watch = Stopwatch.StartNew(); var rows = Enumerable.Range(0, 10000).Select(index => new FileSearchResultViewModel( new FileSearchMatch(new FileSearchEntry($@"C:\not-read-{index}.txt", false), 0), service)).ToArray(); Ensure(calls == 0 && service.Counts.Pending == 0, "构建搜索结果提前读取了所有文件图标。"); var uiThread = Environment.CurrentManagedThreadId; var notificationThread = 0; rows[0].PropertyChanged += (_, arguments) => { if (arguments.PropertyName == nameof(FileSearchResultViewModel.IconSource)) notificationThread = Environment.CurrentManagedThreadId; }; watch.Restart(); Ensure(rows[0].IconSource is null && watch.Elapsed < TimeSpan.FromMilliseconds(100), "首次可见图标绑定阻塞了 UI 线程。"); Ensure(entered.Wait(TimeSpan.FromSeconds(3)), "可见条目没有请求图标。"); var pulse = false; Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() => pulse = true)); Pump(); Ensure(pulse && !rows[0].HasIcon, "后台图标读取阻塞了 UI Dispatcher。"); release.Set(); watch.Restart(); while (!rows[0].HasIcon && watch.Elapsed < TimeSpan.FromSeconds(3)) { Pump(); Thread.Sleep(1); } Ensure(rows[0].IconSource is { IsFrozen: true } && notificationThread == uiThread, "图标属性更新没有异步返回原 UI Dispatcher。"); foreach (var row in rows) row.Dispose(); }); } [Test] public static void RealWindowsShellReturnsDocumentFolderExecutableAndShortcutIcons() { RunSta(() => { var root = Path.Combine(Path.GetTempPath(), "xfetoolbox-shell-icons-" + Guid.NewGuid().ToString("N")); var shortcutPath = Path.Combine(root, "notepad.lnk"); object? shell = null; object? shortcut = null; try { Directory.CreateDirectory(root); var executable = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "notepad.exe"); Ensure(File.Exists(executable), "本机记事本可执行文件不存在。"); shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell", throwOnError: true)!); shortcut = ((dynamic)shell!).CreateShortcut(shortcutPath); ((dynamic)shortcut).TargetPath = executable; ((dynamic)shortcut).Save(); using var service = new ShellFileIconService(); var folder = service.GetIconAsync(root, true).GetAwaiter().GetResult(); var document = service.GetIconAsync(Path.Combine(root, "example.txt"), false).GetAwaiter().GetResult(); var exe = service.GetIconAsync(executable, false).GetAwaiter().GetResult(); var link = service.GetIconAsync(shortcutPath, false).GetAwaiter().GetResult(); Ensure(new[] { folder, document, exe, link }.All(image => image is BitmapSource { IsFrozen: true, PixelWidth: >= 32 }), "Windows Shell 文件/文件夹/EXE/快捷方式图标缺失、未冻结或尺寸过小。"); Ensure(ReferenceEquals(document, service.GetIconAsync(Path.Combine(root, "other.TXT"), false).GetAwaiter().GetResult()) && !ReferenceEquals(exe, link), "类型共享缓存或路径专用快捷方式图标错误。"); } finally { if (shortcut is not null) Marshal.FinalReleaseComObject(shortcut); if (shell is not null) Marshal.FinalReleaseComObject(shell); if (File.Exists(shortcutPath)) File.Delete(shortcutPath); if (Directory.Exists(root)) Directory.Delete(root); } }); } private static ImageSource TestImage() => new DrawingImage(new GeometryDrawing(Brushes.CornflowerBlue, null, new RectangleGeometry(new Rect(0, 0, 32, 32)))); private static void RunSta(Action action) { Exception? failure = null; var thread = new Thread(() => { try { SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher)); action(); } catch (Exception exception) { failure = exception; } finally { Dispatcher.CurrentDispatcher.InvokeShutdown(); } }) { IsBackground = true }; thread.SetApartmentState(ApartmentState.STA); thread.Start(); Ensure(thread.Join(TimeSpan.FromSeconds(20)), "Shell 图标 STA 回归测试超时。"); if (failure is not null) throw new InvalidOperationException("Shell 文件图标回归失败:" + failure.Message, failure); } private static void Pump() { var frame = new DispatcherFrame(); Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() => frame.Continue = false)); Dispatcher.PushFrame(frame); } private static void Ensure(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } }