using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Windows.Threading;
using XFEToolBox.Client.Utilities;
using XFEToolBox.Client.Views.Windows;
namespace XFEToolBox.Client.Wpf.Test;
public static class QuickSearchResponsivenessTests
{
[Test]
public static async Task CachedPrefixCanBeQueriedBeforeRuntimeFinishesRestoring()
{
const int entryCount = 250_000;
var root = Path.Combine(Path.GetTempPath(), "xfetoolbox-runtime-prefix-test-" + Guid.NewGuid().ToString("N"));
var scope = Path.Combine(root, "scope");
var cache = Path.Combine(root, "cache");
var target = Path.Combine(scope, "cacheprefixneedle.txt");
try
{
await Task.Run(() =>
{
Directory.CreateDirectory(scope);
Directory.CreateDirectory(cache);
File.WriteAllText(target, "prefix result");
File.WriteAllText(Path.Combine(cache, "settings.json"), JsonSerializer.Serialize(new { Folder = scope }));
using var file = File.Create(Path.Combine(cache, "files.v1.gz"));
using var compressed = new GZipStream(file, CompressionLevel.Fastest);
using var writer = new BinaryWriter(compressed, Encoding.UTF8);
writer.Write(0x58464958);
writer.Write(1);
WriteCacheString(writer, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scope.ToUpperInvariant()))));
writer.Write(entryCount);
WriteCacheString(writer, target);
writer.Write((byte)0);
for (var index = 1; index < entryCount; index++)
{
WriteCacheString(writer, Path.Combine(scope, $"cached-project-{index / 500:D4}", $"report-{index:D8}-2026.txt"));
writer.Write((byte)0);
}
});
await using var runtime = new FileSearchRuntime(cache);
var elapsed = Stopwatch.StartNew();
await runtime.InitializeAsync();
Ensure(runtime.IsRestoringIndex && runtime.Status?.IsScanning == true,
"运行时初始化等待了完整缓存恢复,无法立即提供前缀搜索。");
while (runtime.Status?.IndexedCount == 0 && elapsed.Elapsed < TimeSpan.FromSeconds(10))
await Task.Delay(1);
Ensure(runtime.Status is { IndexedCount: > 0 and < entryCount }, "缓存没有分批发布可搜索条目。");
var queryElapsed = Stopwatch.StartNew();
var page = await runtime.SearchAsync("cacheprefixneedle", includeContent: false, limit: 10);
Ensure(page.Items.Any(item => string.Equals(item.Entry.FullPath, target, StringComparison.OrdinalIgnoreCase)),
"缓存恢复期间不能搜索已经发布的前缀条目。");
Ensure(runtime.IsRestoringIndex && runtime.Status?.IndexedCount < entryCount,
"搜索仍等待完整缓存恢复之后才返回结果。");
Console.WriteLine($"Runtime cached prefix available at {elapsed.Elapsed.TotalMilliseconds:F1} ms; query {queryElapsed.Elapsed.TotalMilliseconds:F1} ms; hydrated {runtime.Status!.IndexedCount:N0}/{entryCount:N0}.");
}
finally
{
// Only known fixture files exist; avoid recursive deletion and validate the exact parent.
var resolved = Path.GetFullPath(root);
Ensure(Path.GetDirectoryName(resolved) == Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())) &&
Path.GetFileName(resolved).StartsWith("xfetoolbox-runtime-prefix-test-", StringComparison.Ordinal), "Unexpected cache fixture cleanup target.");
if (File.Exists(target)) File.Delete(target);
if (Directory.Exists(cache))
{
foreach (var file in Directory.EnumerateFiles(cache, "*", SearchOption.TopDirectoryOnly)) File.Delete(file);
Directory.Delete(cache);
}
if (Directory.Exists(scope)) Directory.Delete(scope);
if (Directory.Exists(resolved)) Directory.Delete(resolved);
}
}
private static void WriteCacheString(BinaryWriter writer, string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
writer.Write(bytes.Length);
writer.Write(bytes);
}
[Test]
public static void CancellingPaletteSearchDoesNotRunWorkerCleanupOnUiThread()
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher));
// A cancellation callback can interrupt SQLite or release filesystem resources.
// Make that cleanup wait deterministically so a synchronous Cancel would freeze input.
using var workerRelease = new ManualResetEventSlim();
using var workerStarted = new ManualResetEventSlim();
using var workerFinished = new ManualResetEventSlim();
using var cancellation = new CancellationTokenSource();
var uiThreadId = Environment.CurrentManagedThreadId;
var cleanupThreadId = 0;
using var registration = cancellation.Token.Register(() =>
{
cleanupThreadId = Environment.CurrentManagedThreadId;
workerStarted.Set();
workerRelease.Wait(TimeSpan.FromSeconds(2));
workerFinished.Set();
});
// No palette is shown and no runtime API is called: the test never starts an all-drive scan.
var runtime = new FileSearchRuntime(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "xfetoolbox-search-ui-test-unused"));
var window = new CommandPaletteWindow(runtime);
typeof(CommandPaletteWindow).GetField("searchCancellation", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(window, cancellation);
var elapsed = Stopwatch.StartNew();
try
{
typeof(CommandPaletteWindow).GetMethod("CancelSearch", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(window, null);
}
finally { workerRelease.Set(); }
elapsed.Stop();
Ensure(workerStarted.Wait(TimeSpan.FromSeconds(5)) && workerFinished.Wait(TimeSpan.FromSeconds(5)),
"搜索取消没有通知工作线程完成清理。");
window.Close();
runtime.DisposeAsync().AsTask().GetAwaiter().GetResult();
Ensure(elapsed.Elapsed < TimeSpan.FromMilliseconds(250),
$"取消搜索在 UI 线程等待了清理回调:{elapsed.Elapsed.TotalMilliseconds:F1} ms。");
Ensure(cleanupThreadId != uiThreadId, "搜索取消清理仍在界面线程执行。");
}
catch (Exception error) { failure = error; }
finally { Dispatcher.CurrentDispatcher.InvokeShutdown(); }
}) { IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Ensure(thread.Join(TimeSpan.FromSeconds(15)), "搜索取消响应测试超时。");
if (failure is not null) throw new InvalidOperationException("搜索取消仍可能阻塞输入。", failure);
}
private static void Ensure(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
}
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Windows.Threading;
using XFEToolBox.Client.Utilities;
using XFEToolBox.Client.Views.Windows;
namespace XFEToolBox.Client.Wpf.Test;
public static class QuickSearchResponsivenessTests
{
[Test]
public static async Task CachedPrefixCanBeQueriedBeforeRuntimeFinishesRestoring()
{
const int entryCount = 250_000;
var root = Path.Combine(Path.GetTempPath(), "xfetoolbox-runtime-prefix-test-" + Guid.NewGuid().ToString("N"));
var scope = Path.Combine(root, "scope");
var cache = Path.Combine(root, "cache");
var target = Path.Combine(scope, "cacheprefixneedle.txt");
try
{
await Task.Run(() =>
{
Directory.CreateDirectory(scope);
Directory.CreateDirectory(cache);
File.WriteAllText(target, "prefix result");
File.WriteAllText(Path.Combine(cache, "settings.json"), JsonSerializer.Serialize(new { Folder = scope }));
using var file = File.Create(Path.Combine(cache, "files.v1.gz"));
using var compressed = new GZipStream(file, CompressionLevel.Fastest);
using var writer = new BinaryWriter(compressed, Encoding.UTF8);
writer.Write(0x58464958);
writer.Write(1);
WriteCacheString(writer, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scope.ToUpperInvariant()))));
writer.Write(entryCount);
WriteCacheString(writer, target);
writer.Write((byte)0);
for (var index = 1; index < entryCount; index++)
{
WriteCacheString(writer, Path.Combine(scope, $"cached-project-{index / 500:D4}", $"report-{index:D8}-2026.txt"));
writer.Write((byte)0);
}
});
await using var runtime = new FileSearchRuntime(cache);
var elapsed = Stopwatch.StartNew();
await runtime.InitializeAsync();
Ensure(runtime.IsRestoringIndex && runtime.Status?.IsScanning == true,
"运行时初始化等待了完整缓存恢复,无法立即提供前缀搜索。");
while (runtime.Status?.IndexedCount == 0 && elapsed.Elapsed < TimeSpan.FromSeconds(10))
await Task.Delay(1);
Ensure(runtime.Status is { IndexedCount: > 0 and < entryCount }, "缓存没有分批发布可搜索条目。");
var queryElapsed = Stopwatch.StartNew();
var page = await runtime.SearchAsync("cacheprefixneedle", includeContent: false, limit: 10);
Ensure(page.Items.Any(item => string.Equals(item.Entry.FullPath, target, StringComparison.OrdinalIgnoreCase)),
"缓存恢复期间不能搜索已经发布的前缀条目。");
Ensure(runtime.IsRestoringIndex && runtime.Status?.IndexedCount < entryCount,
"搜索仍等待完整缓存恢复之后才返回结果。");
Console.WriteLine($"Runtime cached prefix available at {elapsed.Elapsed.TotalMilliseconds:F1} ms; query {queryElapsed.Elapsed.TotalMilliseconds:F1} ms; hydrated {runtime.Status!.IndexedCount:N0}/{entryCount:N0}.");
}
finally
{
// Only known fixture files exist; avoid recursive deletion and validate the exact parent.
var resolved = Path.GetFullPath(root);
Ensure(Path.GetDirectoryName(resolved) == Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())) &&
Path.GetFileName(resolved).StartsWith("xfetoolbox-runtime-prefix-test-", StringComparison.Ordinal), "Unexpected cache fixture cleanup target.");
if (File.Exists(target)) File.Delete(target);
if (Directory.Exists(cache))
{
foreach (var file in Directory.EnumerateFiles(cache, "*", SearchOption.TopDirectoryOnly)) File.Delete(file);
Directory.Delete(cache);
}
if (Directory.Exists(scope)) Directory.Delete(scope);
if (Directory.Exists(resolved)) Directory.Delete(resolved);
}
}
private static void WriteCacheString(BinaryWriter writer, string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
writer.Write(bytes.Length);
writer.Write(bytes);
}
[Test]
public static void CancellingPaletteSearchDoesNotRunWorkerCleanupOnUiThread()
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher));
// A cancellation callback can interrupt SQLite or release filesystem resources.
// Make that cleanup wait deterministically so a synchronous Cancel would freeze input.
using var workerRelease = new ManualResetEventSlim();
using var workerStarted = new ManualResetEventSlim();
using var workerFinished = new ManualResetEventSlim();
using var cancellation = new CancellationTokenSource();
var uiThreadId = Environment.CurrentManagedThreadId;
var cleanupThreadId = 0;
using var registration = cancellation.Token.Register(() =>
{
cleanupThreadId = Environment.CurrentManagedThreadId;
workerStarted.Set();
workerRelease.Wait(TimeSpan.FromSeconds(2));
workerFinished.Set();
});
// No palette is shown and no runtime API is called: the test never starts an all-drive scan.
var runtime = new FileSearchRuntime(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "xfetoolbox-search-ui-test-unused"));
var window = new CommandPaletteWindow(runtime);
typeof(CommandPaletteWindow).GetField("searchCancellation", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(window, cancellation);
var elapsed = Stopwatch.StartNew();
try
{
typeof(CommandPaletteWindow).GetMethod("CancelSearch", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(window, null);
}
finally { workerRelease.Set(); }
elapsed.Stop();
Ensure(workerStarted.Wait(TimeSpan.FromSeconds(5)) && workerFinished.Wait(TimeSpan.FromSeconds(5)),
"搜索取消没有通知工作线程完成清理。");
window.Close();
runtime.DisposeAsync().AsTask().GetAwaiter().GetResult();
Ensure(elapsed.Elapsed < TimeSpan.FromMilliseconds(250),
$"取消搜索在 UI 线程等待了清理回调:{elapsed.Elapsed.TotalMilliseconds:F1} ms。");
Ensure(cleanupThreadId != uiThreadId, "搜索取消清理仍在界面线程执行。");
}
catch (Exception error) { failure = error; }
finally { Dispatcher.CurrentDispatcher.InvokeShutdown(); }
}) { IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Ensure(thread.Join(TimeSpan.FromSeconds(15)), "搜索取消响应测试超时。");
if (failure is not null) throw new InvalidOperationException("搜索取消仍可能阻塞输入。", failure);
}
private static void Ensure(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
}