Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,352 @@
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);
}
}