using SQLInsight.Core.Servers; namespace SQLInsight.Core.Tests; public class DiskForecastTests { private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero); /// Hourly samples for days back, free space from a function of the age in days. private static List History(int days, Func free, double size = 100) => Enumerable.Range(0, (days * 24) + 1).Select(h => Now.AddHours(-h)).Select(at => new DiskHistoryPoint(at, size, free((Now - at).TotalDays))).Reverse().ToList(); [Fact] public void Steady_growth_gives_days_until_full() { // 1 GB per day used, 20 GB free now: full in 20 days. var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 20 + age), Now); Assert.NotNull(forecast.DaysUntilFull); Assert.Equal(20, forecast.DaysUntilFull!.Value, 1); Assert.Equal(1, forecast.GrowthGbPerDay, 3); Assert.Equal(1, forecast.Fit, 3); Assert.Equal("fuld om ca. 20 dage", forecast.Describe()); Assert.Equal(Now.AddDays(20).Date, forecast.FullAt(Now)!.Value.Date); } [Fact] public void Noise_around_a_trend_still_forecasts() { var random = new Random(7); var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 30 + (2 * age) + (random.NextDouble() - 0.5)), Now); Assert.InRange(forecast.DaysUntilFull!.Value, 14, 16); } [Fact] public void A_flat_or_shrinking_volume_has_no_forecast() { Assert.Null(DiskForecaster.Forecast(@"C:\", History(14, _ => 40), Now).DaysUntilFull); var emptying = DiskForecaster.Forecast(@"C:\", History(14, age => 40 - age), Now); Assert.Null(emptying.DaysUntilFull); Assert.Equal("fyldes ikke op", emptying.Note); } [Fact] public void A_sawtooth_of_backups_written_and_deleted_is_not_a_trend() { // Fills 10 GB every night and is cleaned up every morning. var forecast = DiskForecaster.Forecast(@"E:\", History(14, age => 50 - ((age % 1) * 10)), Now); Assert.Null(forecast.DaysUntilFull); } [Fact] public void Too_little_history_says_so() { var forecast = DiskForecaster.Forecast(@"D:\", History(1, age => 20 + (5 * age)), Now); Assert.Null(forecast.DaysUntilFull); Assert.Contains("for lidt historik", forecast.Note, StringComparison.Ordinal); } [Fact] public void An_extended_volume_starts_a_new_trend() { // Was 100 GB and filling fast until 5 days ago; extended to 200 GB and now flat. var history = History(14, age => age > 5 ? 5 + age : 100).Select(p => (Now - p.At).TotalDays > 5 ? p : p with { SizeGb = 200 }).ToList(); var forecast = DiskForecaster.Forecast(@"D:\", history, Now); Assert.Null(forecast.DaysUntilFull); Assert.InRange(forecast.HistoryDays, 4.9, 5.1); } [Fact] public void A_full_volume_is_full_now_and_far_futures_are_not_forecasts() { Assert.Equal(0, DiskForecaster.Forecast(@"D:\", History(14, age => Math.Max(0, age - 1)), Now).DaysUntilFull); var slow = DiskForecaster.Forecast(@"D:\", History(14, age => 500 + (0.5 * age)), Now, new DiskForecastSettings()); Assert.Null(slow.DaysUntilFull); Assert.Equal("fuld om mere end et år", slow.Note); } [Fact] public void Samples_older_than_the_history_window_are_ignored() { // Fast growth 20–40 days ago, flat for the last 14. var history = Enumerable.Range(0, 40 * 24).Select(h => Now.AddHours(-h)) .Select(at => new DiskHistoryPoint(at, 100, (Now - at).TotalDays > 20 ? (Now - at).TotalDays : 30)).Reverse().ToList(); Assert.Null(DiskForecaster.Forecast(@"D:\", history, Now).DaysUntilFull); } [Fact] public void Only_too_little_history_is_not_enough_history() { Assert.False(DiskForecaster.Forecast(@"D:\", [], Now).EnoughHistory); Assert.False(DiskForecaster.Forecast(@"D:\", History(1, age => 20 + age), Now).EnoughHistory); Assert.True(DiskForecaster.Forecast(@"D:\", History(14, _ => 40), Now).EnoughHistory); // flat: judged, and not filling up Assert.True(DiskForecaster.Forecast(@"D:\", History(14, age => 20 + age), Now).EnoughHistory); } [Fact] public void Days_are_counted_from_now_not_from_the_last_sample() { // 1 GB per day used; the last sample is two days old and had 20 GB free: full in 18 days from now. var history = History(14, age => 20 + age).Select(p => p with { At = p.At.AddDays(-2) }).ToList(); var forecast = DiskForecaster.Forecast(@"D:\", history, Now); Assert.Equal(18, forecast.DaysUntilFull!.Value, 1); Assert.Equal(Now.AddDays(18).Date, forecast.FullAt(Now)!.Value.Date); } [Fact] public void Irregular_sampling_gives_the_same_trend() { // Samples at uneven times (the agent was down now and then), 2 GB per day used, 30 GB free now. var random = new Random(3); var at = Now.AddDays(-14); var history = new List(); while (at <= Now) { history.Add(new DiskHistoryPoint(at, 100, 30 + (2 * (Now - at).TotalDays))); at = at.AddMinutes(15 + random.Next(0, 600)); } history.Add(new DiskHistoryPoint(Now, 100, 30)); var forecast = DiskForecaster.Forecast(@"D:\", history, Now); Assert.Equal(2, forecast.GrowthGbPerDay, 6); Assert.Equal(15, forecast.DaysUntilFull!.Value, 3); } [Fact] public void Identical_timestamps_have_no_slope() { var points = Enumerable.Range(0, 20).Select(i => new DiskHistoryPoint(Now, 100, 50 - i)).ToList(); Assert.Equal((0, 0), DiskForecaster.Regression(points)); Assert.Null(DiskForecaster.Forecast(@"D:\", points, Now, new DiskForecastSettings { MinimumHistoryDays = 0, MinimumSamples = 2 }).DaysUntilFull); } [Fact] public void A_shrunk_volume_starts_a_new_trend() { // Was 200 GB and flat; shrunk to 100 GB a day ago and filling fast since: too little history for the new size. var history = History(14, age => age > 1 ? 150 : 20 + (10 * age)).Select(p => (Now - p.At).TotalDays > 1 ? p with { SizeGb = 200 } : p).ToList(); var forecast = DiskForecaster.Forecast(@"D:\", history, Now); Assert.Null(forecast.DaysUntilFull); Assert.False(forecast.EnoughHistory); Assert.InRange(forecast.HistoryDays, 0.9, 1.1); } } public class ServerPerformanceTests { private static readonly DateTimeOffset T0 = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero); [Fact] public void Cpu_is_one_minus_the_idle_share_of_the_interval() { var first = new ServerPerformanceRaw { CapturedAt = T0, ProcessorIdle100Ns = 1_000_000_000, ProcessorTimestamp100Ns = 5_000_000_000 }; // One minute = 600,000,000 × 100 ns; idle 150,000,000 of it → 75 % busy. var second = first with { CapturedAt = T0.AddMinutes(1), ProcessorIdle100Ns = 1_150_000_000, ProcessorTimestamp100Ns = 5_600_000_000 }; Assert.Equal(75, ServerPerformanceMath.Cpu(first, second)!.Value, 6); Assert.Null(ServerPerformanceMath.Cpu(null, second)); Assert.Null(ServerPerformanceMath.Cpu(second, first)); // counters went backwards: rebooted } [Fact] public void Pages_per_second_uses_the_performance_frequency() { var first = new ServerPerformanceRaw { CapturedAt = T0, Pages = 1000, MemoryPerfTime = 10_000_000, MemoryPerfFrequency = 10_000_000 }; var second = first with { CapturedAt = T0.AddMinutes(1), Pages = 61_000, MemoryPerfTime = 610_000_000 }; Assert.Equal(1000, ServerPerformanceMath.PagesPerSecond(first, second)!.Value, 6); } [Fact] public void Sample_has_available_percent_and_commit() { var raw = new ServerPerformanceRaw { CapturedAt = T0, AvailableMb = 1024, CommittedBytes = 9, CommitLimit = 10 }; var sample = ServerPerformanceMath.Sample(null, raw, new ServerOsInfo { TotalMemoryMb = 8192 }); Assert.Equal(12.5, sample.AvailablePercent!.Value, 6); Assert.Equal(90, sample.CommitPercent!.Value, 6); Assert.Null(sample.CpuPercent); // Not rounded down to whole percent: 89.9 % stays under a 90 % limit only because it is under it. var big = ServerPerformanceMath.Sample(null, raw with { CommittedBytes = 8_999_000_000, CommitLimit = 10_000_000_000 }, new ServerOsInfo()); Assert.Equal(89.99, big.CommitPercent!.Value, 6); } [Fact] public void A_sample_after_a_long_gap_has_no_rates() { var first = new ServerPerformanceRaw { CapturedAt = T0, ProcessorIdle100Ns = 1_000_000_000, ProcessorTimestamp100Ns = 5_000_000_000, Pages = 1000, MemoryPerfTime = 10_000_000, MemoryPerfFrequency = 10_000_000, }; var minute = first with { CapturedAt = T0.AddMinutes(1), ProcessorIdle100Ns = 1_150_000_000, ProcessorTimestamp100Ns = 5_600_000_000, Pages = 61_000, MemoryPerfTime = 610_000_000 }; var hourLater = minute with { CapturedAt = T0.AddMinutes(61) }; var os = new ServerOsInfo(); Assert.Equal(75, ServerPerformanceMath.Sample(first, minute, os, TimeSpan.FromMinutes(1)).CpuPercent!.Value, 6); var afterGap = ServerPerformanceMath.Sample(first, hourLater, os, TimeSpan.FromMinutes(1)); Assert.Null(afterGap.CpuPercent); Assert.Null(afterGap.PagesPerSecond); Assert.NotNull(ServerPerformanceMath.Sample(first, hourLater, os, TimeSpan.FromMinutes(30)).CpuPercent); // within three intervals } [Fact] public void Sustained_needs_most_of_the_period_covered() { var samples = Enumerable.Range(0, 15).Select(m => new ServerPerformanceSample(T0.AddMinutes(-m), 95, null, null, null, null)).Reverse().ToList(); Assert.Equal(95, ServerPerformanceMath.SustainedAverage(samples, s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1))); Assert.Null(ServerPerformanceMath.SustainedAverage(samples.TakeLast(5).ToList(), s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1))); } [Fact] public void The_window_keeps_an_hour() { IReadOnlyList window = []; for (var m = 0; m < 90; m++) { window = ServerPerformanceMath.Append(window, new ServerPerformanceSample(T0.AddMinutes(m), 10, null, null, null, null)); } Assert.Equal(60, window.Count); Assert.Equal(T0.AddMinutes(89), window[^1].At); } } public class ServerEventTests { private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero); private static ServerEventEntry Entry(string source, long id, int level = 2, string log = "System", int minutesAgo = 1, string? message = "tekst") => new(log, source, id, level, Now.AddMinutes(-minutesAgo), message); [Theory] [InlineData("Microsoft-Windows-Kernel-Power", 41, 1, ServerEventCategory.UnexpectedShutdown)] [InlineData("EventLog", 6008, 2, ServerEventCategory.UnexpectedShutdown)] [InlineData("disk", 153, 3, ServerEventCategory.Disk)] [InlineData("storvsc", 129, 3, ServerEventCategory.Disk)] [InlineData("Ntfs", 55, 2, ServerEventCategory.Disk)] [InlineData("Srv", 2019, 2, ServerEventCategory.Resources)] [InlineData("Service Control Manager", 7031, 2, ServerEventCategory.ServiceFailure)] [InlineData("Application Error", 1000, 2, ServerEventCategory.ApplicationCrash)] [InlineData("Microsoft Dynamics AX Object Server 6.0$01", 110, 2, ServerEventCategory.DynamicsAx)] [InlineData("Some-Driver", 5, 1, ServerEventCategory.Critical)] [InlineData("Some-App", 5, 2, ServerEventCategory.Error)] public void Categorize(string source, long id, int level, ServerEventCategory expected) => Assert.Equal(expected, ServerEventAnalysis.Categorize(Entry(source, id, level))); [Fact] public void Repeated_events_are_one_group_with_the_newest_sample() { var (groups, ignored) = ServerEventAnalysis.Group( [ Entry("Service Control Manager", 7031, minutesAgo: 30, message: "AOS stoppede (1)"), Entry("Service Control Manager", 7031, minutesAgo: 5, message: "AOS stoppede (2)"), Entry("service control manager", 7031, minutesAgo: 10, message: null), Entry("Some-App", 9, log: "Application"), Entry("Some-App", 9, level: 3, log: "Application"), // a warning: not collected ], ServerEventAnalysis.DefaultIgnore); Assert.Equal(0, ignored); Assert.Equal(2, groups.Count); var scm = groups[0]; Assert.Equal(ServerEventCategory.ServiceFailure, scm.Category); Assert.Equal(3, scm.Count); Assert.Equal("AOS stoppede (2)", scm.Sample); Assert.Equal(Now.AddMinutes(-30), scm.FirstAt); Assert.Equal(Now.AddMinutes(-5), scm.LastAt); } [Fact] public void Known_noise_and_sql_server_sources_are_left_out() { var (groups, ignored) = ServerEventAnalysis.Group( [ Entry("Microsoft-Windows-DistributedCOM", 10016), Entry("Schannel", 36887), Entry("MSSQL$PROD", 17053, log: "Application"), Entry("MSSQLSERVER", 823, log: "Application"), Entry("Schannel", 36871), ], ServerEventAnalysis.DefaultIgnore); Assert.Equal(4, ignored); Assert.Equal(36871, Assert.Single(groups).EventId); } [Theory] [InlineData("Some-App:9", true)] [InlineData("Some-App", true)] [InlineData("Some*", true)] [InlineData("Some*:10", false)] [InlineData("Some-App:*", true)] [InlineData("Other:9", false)] public void Ignore_patterns(string pattern, bool ignored) => Assert.Equal(ignored, ServerEventAnalysis.IsIgnored(Entry("Some-App", 9), [pattern])); [Fact] public void Recent_merges_groups_of_the_last_day_and_drops_older() { var (earlier, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 60 * 20), Entry("Some-App", 9, minutesAgo: 60 * 30)], []); var (current, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 2)], []); var recent = ServerEventAnalysis.Recent(earlier, current, Now); var disk = Assert.Single(recent); Assert.Equal(2, disk.Count); Assert.Equal(Now.AddHours(-20), disk.FirstAt); Assert.Equal(Now.AddMinutes(-2), disk.LastAt); } [Fact] public void The_24_hour_count_only_counts_the_last_24_hours() { // The same error every ten hours for three days, collected every five minutes as the agent does. IReadOnlyList recent = []; var start = Now.AddDays(-3); for (var at = start; at <= Now; at = at.AddMinutes(5)) { var entries = (at - start).TotalMinutes % 600 == 0 ? new[] { new ServerEventEntry("Application", "Some-App", 9, 2, at, "tekst") } : []; var (groups, _) = ServerEventAnalysis.Group(entries, []); recent = ServerEventAnalysis.Recent(recent, groups, at); } var group = Assert.Single(recent); Assert.Equal(3, group.Count); // not the 8 seen since the first one Assert.True(Now - group.FirstAt <= ServerEventAnalysis.RecentWindow); Assert.InRange(group.Buckets.Count, 1, 25); } [Fact] public void Groups_saved_without_hourly_counts_are_still_merged() { var old = new ServerEventGroup("System", "disk", 153, 2, ServerEventCategory.Disk, 4, Now.AddHours(-5), Now.AddHours(-3), "tekst"); var (current, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 2)], []); var disk = Assert.Single(ServerEventAnalysis.Recent([old], current, Now)); Assert.Equal(5, disk.Count); Assert.Equal(Now.AddHours(-5), disk.FirstAt); Assert.Empty(ServerEventAnalysis.Recent([old], [], Now.AddHours(22))); } [Fact] public void Samples_are_cut() { var (groups, _) = ServerEventAnalysis.Group([Entry("Some-App", 9, message: new string('x', 2000))], []); Assert.Equal(500, groups[0].Sample!.Length); } } public class ServerConfigurationTests { [Theory] [InlineData("aos01", null)] [InlineData("aos01.firma.local", null)] [InlineData("10.0.0.5", null)] [InlineData("fe80::1", null)] [InlineData(@"sql01\PROD", "uden instansnavn")] [InlineData("aos01:2712", "uden instansnavn")] [InlineData("", "Angiv")] public void Hosts(string host, string? error) { var result = ServerHost.Validate(host); if (error is null) { Assert.Null(result); } else { Assert.Contains(error, result, StringComparison.Ordinal); } } [Theory] [InlineData(@"tcp:SQL01.firma.local\PROD,1433", "sql01.firma.local", false)] [InlineData("sql02,1500", "sql02", false)] [InlineData(@".\SQLEXPRESS", "agent01", false)] [InlineData("(local)", "agent01", false)] [InlineData("localhost", "agent01", false)] [InlineData("(localdb)\\MSSQLLocalDB", null, true)] [InlineData("kunde.database.windows.net", null, true)] public void The_computer_behind_a_sql_data_source(string dataSource, string? host, bool skipped) { var (found, reason) = ServerHost.FromDataSource(dataSource, "AGENT01"); Assert.Equal(host, found); Assert.Equal(skipped, reason is not null); } [Fact] public void Lists_of_service_names() { Assert.Equal(["AOS60$01", "W3SVC"], ServerHost.ParseList("AOS60$01, W3SVC\nw3svc;")); Assert.Null(ServerHost.ValidateServiceNames(["AOS60$01", "MSSQL$PROD"])); Assert.NotNull(ServerHost.ValidateServiceNames([@"bad\name"])); } [Theory] [InlineData(@"NT SERVICE\SQLInsight Agent", "aos01.firma.local", @"FIRMA\AGENT01$")] [InlineData("LocalSystem", "aos01", @"FIRMA\AGENT01$")] [InlineData(@"NT SERVICE\SQLInsight Agent", "agent01.firma.local", @"NT SERVICE\SQLInsight Agent")] [InlineData(@"FIRMA\gmsa-sqlinsight$", "aos01", @"FIRMA\gmsa-sqlinsight$")] public void The_account_the_agent_arrives_as(string service, string host, string expected) => Assert.Equal(expected, ServerGrantScript.AccountFor(service, host, "AGENT01", "FIRMA")); [Theory] [InlineData(0u, false)] [InlineData(TaskResults.Running, false)] [InlineData(TaskResults.HasNotRun, false)] [InlineData(TaskResults.AlreadyRunning, false)] [InlineData(1u, true)] [InlineData(0x80070005u, true)] [InlineData(TaskResults.TerminatedByUser, true)] public void Task_results(uint result, bool failure) { Assert.Equal(failure, TaskResults.IsFailure(result)); Assert.False(string.IsNullOrWhiteSpace(TaskResults.Describe(result))); } }