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<string>();
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<Task<ImageSource?>>();
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);
}
}
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<string>();
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<Task<ImageSource?>>();
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);
}
}