353 lines
14 KiB
C#
353 lines
14 KiB
C#
using Omika.Diagnostics.Models;
|
|
using Omika.Diagnostics.Queries;
|
|
using Omika.Diagnostics.Tests.Fakes;
|
|
|
|
namespace Omika.Diagnostics.Tests;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="FakeDiagnosticEventRepository"/> ; la production exécute les mêmes contrats
|
|
/// via le CTE PostgreSQL <c>latest_device</c> (DISTINCT ON / COUNT(DISTINCT device_key)).
|
|
/// </summary>
|
|
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<DiagnosticEventRecord>();
|
|
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<DeviceDistribution> 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<DeviceDistribution> 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<DeviceDistribution> 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<DeviceDistribution> platforms = await repo.CountDevicesGroupedAsync(
|
|
DeviceFleetDimension.Platform, All());
|
|
DeviceDistribution android = Assert.Single(platforms, p => p.Key == "Android");
|
|
Assert.Equal(2, android.DeviceCount);
|
|
|
|
IReadOnlyList<DeviceDistribution> 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<DeviceDistribution> manufacturers = await repo.CountDevicesGroupedAsync(
|
|
DeviceFleetDimension.Manufacturer, All());
|
|
Assert.Empty(manufacturers); // NULL exclu, pas de faux « Inconnu »
|
|
|
|
IReadOnlyList<DeviceDistribution> 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);
|
|
}
|
|
}
|