using Omika.Diagnostics.Models; using Omika.Diagnostics.Queries; using Omika.Diagnostics.Tests.Fakes; namespace Omika.Diagnostics.Tests; /// /// T17-E — Statistiques de parc : identité appareil = DeviceKey (jamais LicenseNumber, /// réattribuable), dernière observation par DeviceKey, NULL historique, réattribution de /// licence, normalisation analytique et pourcentages. Tests sur /// ; la production exécute les mêmes contrats /// via le CTE PostgreSQL latest_device (DISTINCT ON / COUNT(DISTINCT device_key)). /// public class DiagnosticDeviceFleetTests { private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset To = new(2025, 2, 1, 0, 0, 0, TimeSpan.Zero); private static DeviceFleetQuery All(int? limit = null) => new() { FromUtc = From, ToUtc = To, Limit = limit ?? DiagnosticAggregateDefaults.MaxLimit }; private static DiagnosticEventRecord Ev( string deviceKey, DateTimeOffset ts, string? license = null, string? platform = "Android", string? os = "Android", string? osVersion = "15", string? manufacturer = null, string? model = null, string? appVersion = null, int? screenW = null, int? screenH = null) { DiagnosticEventRecord e = DiagnosticTestData.Event(Guid.NewGuid(), timestamp: ts); e.DeviceKey = deviceKey; e.LicenseNumber = license; e.Platform = platform; e.OperatingSystem = os; e.OperatingSystemVersion = osVersion; e.DeviceManufacturer = manufacturer; e.DeviceModel = model; e.AppVersion = appVersion; e.ScreenWidth = screenW; e.ScreenHeight = screenH; return e; } // ------------------------------- §61 : 1000 événements = 1 appareil ---- [Fact] public async Task OneDeviceWithManyEvents_CountsAsOneDevice() { var repo = new FakeDiagnosticEventRepository(); DiagnosticEventRecord[] events = Enumerable.Range(0, 1000) .Select(i => Ev("dev-a", From.AddSeconds(i))) .ToArray(); await repo.InsertBatchAsync(events); Assert.Equal(1, await repo.CountActiveDevicesAsync(All())); } // ------------------------------- §5/§61 : deux appareils distincts ---- [Fact] public async Task TwoDevices_CountAsTwo_RegardlessOfEventVolume() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("dev-a", From.AddMinutes(1)), Ev("dev-a", From.AddMinutes(2)), Ev("dev-b", From.AddMinutes(3)) }); Assert.Equal(2, await repo.CountActiveDevicesAsync(All())); } // ------------------------------- §62 : DeviceKey NULL → aucun appareil ---- [Fact] public async Task NullDeviceKey_ProducesNoFakeDevice() { var repo = new FakeDiagnosticEventRepository(); var events = new List(); for (int i = 0; i < 5; i++) { DiagnosticEventRecord e = DiagnosticTestData.Event(Guid.NewGuid(), timestamp: From.AddMinutes(i)); e.DeviceKey = null; events.Add(e); } await repo.InsertBatchAsync(events); Assert.Equal(0, await repo.CountActiveDevicesAsync(All())); DeviceFleetQueryResult devices = await repo.GetDevicesAsync(All()); Assert.Equal(0, devices.TotalCount); } // ------------------------------- §63 : dernière observation OS ---- [Fact] public async Task OsVersionDistribution_UsesLastObservation() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("dev-a", From.AddDays(1), osVersion: "15", manufacturer: "Samsung"), Ev("dev-a", From.AddDays(20), osVersion: "16", manufacturer: "Samsung") }); IReadOnlyList result = await repo.CountDevicesGroupedAsync( DeviceFleetDimension.OperatingSystemVersion, All()); DeviceDistribution group = Assert.Single(result); Assert.Equal("Android 16", group.Key); Assert.Equal(1, group.DeviceCount); } // ------------------------------- §64 : dernière observation AppVersion ---- [Fact] public async Task AppVersionDistribution_UsesLastObservation() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("dev-a", From.AddDays(1), appVersion: "1.4.0"), Ev("dev-a", From.AddDays(20), appVersion: "1.4.1") }); IReadOnlyList result = await repo.CountDevicesGroupedAsync( DeviceFleetDimension.AppVersion, All()); DeviceDistribution group = Assert.Single(result); Assert.Equal("1.4.1", group.Key); Assert.Equal(1, group.DeviceCount); } // ------------------------------- §47 : filtre dernière observation OS ---- [Fact] public async Task LastObservationFilter_Android15_ExcludesDeviceNowOnAndroid16() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("dev-a", From.AddDays(1), osVersion: "15"), Ev("dev-a", From.AddDays(20), osVersion: "16") }); var filter15 = new DeviceFleetQuery { FromUtc = From, ToUtc = To, OperatingSystemVersion = "15", Limit = DiagnosticAggregateDefaults.MaxLimit }; Assert.Equal(0, await repo.CountActiveDevicesAsync(filter15)); var filter16 = new DeviceFleetQuery { FromUtc = From, ToUtc = To, OperatingSystemVersion = "16", Limit = DiagnosticAggregateDefaults.MaxLimit }; Assert.Equal(1, await repo.CountActiveDevicesAsync(filter16)); } // ------------------------------- §65/§66 : réattribution = 2 appareils ---- [Fact] public async Task LicenseReassignment_CountsTwoDistinctDevices() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("dev-a", From.AddDays(1), license: "LIC-001", manufacturer: "Samsung", model: "SM-DEMO", os: "Android", osVersion: "15"), Ev("dev-b", From.AddDays(20), license: "LIC-001", manufacturer: "Apple", model: "iPhoneDemo", os: "iOS", osVersion: "18") }); Assert.Equal(2, await repo.CountActiveDevicesAsync(All())); DeviceFleetQueryResult devices = await repo.GetDevicesAsync(All()); Assert.Equal(2, devices.TotalCount); Assert.Contains(devices.Items, d => d.DeviceKey == "dev-a"); Assert.Contains(devices.Items, d => d.DeviceKey == "dev-b"); } // ------------------------------- §66 : jamais de fusion par licence ---- [Fact] public async Task SameLicense_DifferentDeviceKeys_RemainDifferentDevices() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("dev-a", From.AddDays(1), license: "LIC-001"), Ev("dev-a", From.AddDays(2), license: "LIC-001"), Ev("dev-b", From.AddDays(3), license: "LIC-001") }); // Sans filtre : 2 appareils. Avec filtre licence : TOUJOURS 2 appareils distincts, // jamais fusionnés en 1 simplement parce que la licence est identique. Assert.Equal(2, await repo.CountActiveDevicesAsync(All())); Assert.Equal(2, await repo.CountActiveDevicesAsync( new DeviceFleetQuery { FromUtc = From, ToUtc = To, LicenseNumber = "LIC-001", Limit = DiagnosticAggregateDefaults.MaxLimit })); } // ------------------------------- §67 : filtre licence = les deux appareils ---- [Fact] public async Task LicenseFilter_ReturnsAllDevicesThatUsedLicense() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("dev-a", From.AddDays(1), license: "LIC-001"), Ev("dev-b", From.AddDays(20), license: "LIC-001"), Ev("dev-c", From.AddDays(5), license: "LIC-002") }); var query = new DeviceFleetQuery { FromUtc = From, ToUtc = To, LicenseNumber = "LIC-001", Limit = DiagnosticAggregateDefaults.MaxLimit }; DeviceFleetQueryResult devices = await repo.GetDevicesAsync(query); Assert.Equal(2, devices.TotalCount); Assert.All(devices.Items, d => Assert.Equal("LIC-001", d.LicenseNumber)); } // ------------------------------- §16 : appareils distincts par licence ---- [Fact] public async Task DevicesByLicense_CountsDistinctDevicesPerLicense() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("dev-a", From.AddDays(1), license: "LIC-001"), Ev("dev-a", From.AddDays(2), license: "LIC-001"), Ev("dev-b", From.AddDays(3), license: "LIC-001"), Ev("dev-c", From.AddDays(4), license: "LIC-002") }); IReadOnlyList result = await repo.CountDevicesByLicenseAsync(All()); DeviceDistribution lic001 = Assert.Single(result, r => r.Key == "LIC-001"); Assert.Equal(2, lic001.DeviceCount); // dev-a + dev-b, jamais 3 événements DeviceDistribution lic002 = Assert.Single(result, r => r.Key == "LIC-002"); Assert.Equal(1, lic002.DeviceCount); } // ------------------------------- §69 : pourcentages ---- [Fact] public void Percentage_ComputesShareWithoutDivisionByZero() { Assert.Equal(80.0, DeviceFleetStatistics.Percentage(8, 10)); Assert.Equal(20.0, DeviceFleetStatistics.Percentage(2, 10)); Assert.Null(DeviceFleetStatistics.Percentage(5, 0)); Assert.Null(DeviceFleetStatistics.Percentage(0, 0)); } // ------------------------------- §70 : normalisation ---- [Fact] public async Task Normalization_GroupsCaseVariants() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("d1", From.AddDays(1), platform: "Android", manufacturer: "Samsung"), Ev("d2", From.AddDays(2), platform: "ANDROID", manufacturer: "SAMSUNG"), Ev("d3", From.AddDays(3), platform: "iOS", manufacturer: "Apple") }); IReadOnlyList platforms = await repo.CountDevicesGroupedAsync( DeviceFleetDimension.Platform, All()); DeviceDistribution android = Assert.Single(platforms, p => p.Key == "Android"); Assert.Equal(2, android.DeviceCount); IReadOnlyList manufacturers = await repo.CountDevicesGroupedAsync( DeviceFleetDimension.Manufacturer, All()); DeviceDistribution samsung = Assert.Single(manufacturers, m => m.Key == "Samsung"); Assert.Equal(2, samsung.DeviceCount); } [Theory] [InlineData("Android", "Android")] [InlineData("ANDROID", "Android")] [InlineData("android", "Android")] [InlineData("iOS", "iOS")] [InlineData("IOS", "iOS")] [InlineData("macOS", "macOS")] [InlineData("MACOS", "macOS")] [InlineData(null, null)] [InlineData(" ", null)] public void PlatformHelper_NormalizesDeterministically(string? input, string? expected) { Assert.Equal(expected, DeviceFleetNormalization.Platform(input)); } [Theory] [InlineData("Samsung", "Samsung")] [InlineData("SAMSUNG", "Samsung")] [InlineData("samsung", "Samsung")] [InlineData("Xiaomi", "Xiaomi")] [InlineData(null, null)] public void ManufacturerHelper_NormalizesDeterministically(string? input, string? expected) { Assert.Equal(expected, DeviceFleetNormalization.Manufacturer(input)); } [Theory] [InlineData("SM-S928B", "SM-S928B")] [InlineData("iPhone17,2", "iPhone17,2")] [InlineData("23113RKC6G", "23113RKC6G")] [InlineData(" sm-s928b ", "sm-s928b")] // TRIM seulement, casse conservée (§44) [InlineData(null, null)] public void DeviceModelHelper_TrimsAndPreservesCase(string? input, string? expected) { Assert.Equal(expected, DeviceFleetNormalization.DeviceModel(input)); } // ------------------------------- §71 : anciennes données NULL ---- [Fact] public async Task OldData_NullDimensions_NoCrashNoFakeDevice() { var repo = new FakeDiagnosticEventRepository(); DiagnosticEventRecord legacy = DiagnosticTestData.Event(Guid.NewGuid(), timestamp: From.AddDays(1)); legacy.DeviceKey = "legacy-device"; legacy.DeviceManufacturer = null; legacy.OperatingSystem = null; legacy.OperatingSystemVersion = null; legacy.AppVersion = null; legacy.ScreenWidth = null; legacy.ScreenHeight = null; await repo.InsertBatchAsync(new[] { legacy }); Assert.Equal(1, await repo.CountActiveDevicesAsync(All())); // appareil identifiable IReadOnlyList manufacturers = await repo.CountDevicesGroupedAsync( DeviceFleetDimension.Manufacturer, All()); Assert.Empty(manufacturers); // NULL exclu, pas de faux « Inconnu » IReadOnlyList resolutions = await repo.CountDevicesGroupedAsync( DeviceFleetDimension.Resolution, All()); Assert.Empty(resolutions); } // ------------------------------- §55/§56 : pagination + tri LastSeen DESC ---- [Fact] public async Task GetDevices_PaginatesAndSortsByLastSeenDescending() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync(new[] { Ev("d1", From.AddDays(1)), Ev("d2", From.AddDays(3)), Ev("d3", From.AddDays(2)) }); var query = new DeviceFleetQuery { FromUtc = From, ToUtc = To, Page = 1, PageSize = 2, Limit = DiagnosticAggregateDefaults.MaxLimit }; DeviceFleetQueryResult page = await repo.GetDevicesAsync(query); Assert.Equal(3, page.TotalCount); Assert.Equal(2, page.Items.Count); Assert.Equal(2, page.TotalPages); Assert.Equal("d2", page.Items[0].DeviceKey); // dernière activité d'abord Assert.Equal("d3", page.Items[1].DeviceKey); } }