using WxAgent.Core; using Xunit; namespace WxAgent.Core.Tests; public sealed class WechatStabilityTests { [Fact] public void Deduper_HandlesTenThousandMessagesAndEvictsOldEntries() { var deduper = new BoundedMessageDeduper(2048); for (var index = 0; index < 10_000; index++) Assert.True(deduper.TryAdd($"message-{index}")); Assert.False(deduper.TryAdd("message-9999")); Assert.True(deduper.TryAdd("message-0")); } [Theory] [InlineData("Qt51514QWindowIcon", "微信", true)] [InlineData("Qt6QWindowIcon", "微信", true)] [InlineData("Qt51514QWindowIcon", "文件传输助手", false)] [InlineData("Qt51514WxTrayIconMessageWindowClass", "微信", false)] [InlineData("Notepad", "微信", false)] public void NativeWindowMatching_RejectsSubwindowsAndOtherApplications(string className, string title, bool expected) { Assert.Equal(expected, WechatLocators.IsNativeMainWindow(className, title)); } [Theory] [InlineData("微信", true)] [InlineData("微信\n收到新消息", true)] [InlineData("WeChat", true)] [InlineData("企业微信", false)] [InlineData("微信测试脚本", false)] public void TrayMatching_IsExplicit(string name, bool expected) => Assert.Equal(expected, WechatLocators.IsWechatTrayName(name)); [Fact] public void StabilityAnalyzer_DetectsAgentLeakAndFinalError() { var now = DateTimeOffset.UtcNow; var first = Sample(now, 100, 10, 1); var last = first with { Timestamp = now.AddHours(1), AgentHandleCount = 101, Errors = [WxAgentErrorCode.SessionLocked] }; var report = WechatStabilityAnalyzer.Analyze(now, last.Timestamp, [first, last], 0, 0); Assert.False(report.Healthy); Assert.Contains("agent-handle-growth-over-100", report.Findings); Assert.Contains("final-error:SessionLocked", report.Findings); } [Fact] public void Deduper_RejectsInvalidCapacity() { Assert.Throws(() => new BoundedMessageDeduper(0)); } [Fact] public void StabilityAnalyzer_ReportsHealthyBoundedGrowth() { var started = DateTimeOffset.UtcNow; var samples = new[] { Sample(started, 100_000_000, 100, 20), Sample(started.AddMinutes(1), 110_000_000, 120, 22) }; var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(1), samples, 5, 1); Assert.True(report.Healthy); Assert.Equal(10_000_000, report.WorkingSetGrowthBytes); Assert.Empty(report.Findings); } [Fact] public void StabilityAnalyzer_AllowsTransientWindowLossAfterReconnect() { var started = DateTimeOffset.UtcNow; var samples = new[] { Sample(started, 100_000_000, 100, 20), Sample(started.AddMinutes(1), 0, 0, 0, windowFound: false), Sample(started.AddMinutes(2), 105_000_000, 110, 21) }; var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(2), samples, 0, 1); Assert.True(report.Healthy); Assert.DoesNotContain("wechat-window-unavailable", report.Findings); } [Fact] public void StabilityAnalyzer_FlagsWindowLossAndResourceGrowth() { var started = DateTimeOffset.UtcNow; var samples = new[] { Sample(started, 100_000_000, 100, 20), Sample(started.AddHours(1), 400_000_000, 700, 150, windowFound: false) }; var report = WechatStabilityAnalyzer.Analyze(started, started.AddHours(1), samples, 0, 0); Assert.False(report.Healthy); Assert.Contains("wechat-window-unavailable", report.Findings); Assert.Contains("working-set-growth-over-256mb", report.Findings); Assert.Contains("handle-growth-over-500", report.Findings); Assert.Contains("thread-growth-over-100", report.Findings); } [Theory] [InlineData(30, false, 0, true)] [InlineData(15, false, 0, false)] [InlineData(30, true, 0, false)] [InlineData(30, false, 11, false)] public void RunChecks_ApplyDurationCancellationAndHeartbeatToEveryReport(int seconds, bool cancelled, int snapshotAge, bool healthy) { var finish = DateTimeOffset.UtcNow; var last = Sample(finish, 100, 10, 1) with { LastSnapshotAt = finish.AddSeconds(-snapshotAge) }; var report = WechatStabilityAnalyzer.Analyze(finish.AddSeconds(-seconds), finish, [last], 0, 0); var checkedReport = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(seconds), cancelled); Assert.Equal(healthy, checkedReport.Healthy); Assert.Equal(seconds >= 29 && !cancelled, checkedReport.CompletedDuration); } [Fact] public void RunChecks_RejectMissingHeartbeatAndBackgroundFailures() { var finish = DateTimeOffset.UtcNow; var report = WechatStabilityAnalyzer.Analyze(finish.AddHours(-8), finish, [Sample(finish, 0, 0, 0)], 0, 0); report = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromHours(8), TimeSpan.FromHours(8), false, "InvalidOperationException", "IOException"); Assert.False(report.Healthy); Assert.Contains("listener-snapshot-stale", report.Findings); Assert.Contains("listener-failed:InvalidOperationException", report.Findings); Assert.Contains("sampling-failed:IOException", report.Findings); } [Fact] public void StabilityAnalyzer_DoesNotHideMissingHeartbeatBeforeRecovery() { var started = DateTimeOffset.UtcNow; var missing = Sample(started.AddMinutes(6), 100, 10, 1); var recovered = missing with { Timestamp = started.AddMinutes(7), LastSnapshotAt = started.AddMinutes(7) }; var report = WechatStabilityAnalyzer.Analyze(started, recovered.Timestamp, [missing, recovered], 0, 1); Assert.Contains("listener-snapshot-gap-over-5min", report.Findings); Assert.False(report.Healthy); } [Fact] public async Task ReportWrite_PreservesExistingEvidenceWhenCancelled() { var directory = Path.Combine(Path.GetTempPath(), "wx-stability-" + Guid.NewGuid()); Directory.CreateDirectory(directory); try { var path = Path.Combine(directory, "report.json"); await File.WriteAllTextAsync(path, "previous evidence"); var now = DateTimeOffset.UtcNow; var report = WechatStabilityAnalyzer.Analyze(now, now, [Sample(now, 0, 0, 0)], 0, 0); using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); await Assert.ThrowsAnyAsync(() => WechatStabilityAnalyzer.WriteJsonAsync(report, path, cancelled.Token)); Assert.Equal("previous evidence", await File.ReadAllTextAsync(path)); Assert.False(File.Exists(path + ".tmp")); } finally { Directory.Delete(directory, recursive: true); } } [Theory] [InlineData("Shell_TrayWnd", true)] [InlineData("Shell_SecondaryTrayWnd", true)] [InlineData("NotifyIconOverflowWindow", true)] [InlineData("Qt51514QWindowIcon", false)] [InlineData("Chrome_WidgetWin_1", false)] [InlineData("", false)] public void TrayClicks_RequireANotificationAreaHitTarget(string className, bool expected) { Assert.Equal(expected, WechatLocators.IsNotificationAreaRoot(className)); } private static WechatStabilitySample Sample( DateTimeOffset timestamp, long memory, int handles, int threads, bool windowFound = true) => new(timestamp, 1, memory, handles, threads, windowFound, [], 0, 0); }