first commit
This commit is contained in:
28
tests/App.Core.Tests/App.Core.Tests.csproj
Normal file
28
tests/App.Core.Tests/App.Core.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\App.Core\App.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
146
tests/App.Core.Tests/Kpi/KpiCalculatorTests.cs
Normal file
146
tests/App.Core.Tests/Kpi/KpiCalculatorTests.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using App.Core.Entities.Maintenance;
|
||||
using App.Core.Services.Kpi;
|
||||
|
||||
namespace App.Core.Tests.Kpi;
|
||||
|
||||
public class KpiCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeMtbfHours_LessThanTwoFailures_ReturnsNull()
|
||||
{
|
||||
var timestamps = new List<DateTime> { new(2026, 1, 1) };
|
||||
|
||||
Assert.Null(KpiCalculator.ComputeMtbfHours(timestamps));
|
||||
Assert.Null(KpiCalculator.ComputeMtbfHours(new List<DateTime>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMtbfHours_AveragesIntervalsBetweenFailures()
|
||||
{
|
||||
// Pannes le 1er, le 3 (48h après) et le 8 (120h après) -> moyenne (48+120)/2 = 84h.
|
||||
var timestamps = new List<DateTime>
|
||||
{
|
||||
new(2026, 1, 1),
|
||||
new(2026, 1, 3),
|
||||
new(2026, 1, 8),
|
||||
};
|
||||
|
||||
var mtbf = KpiCalculator.ComputeMtbfHours(timestamps);
|
||||
|
||||
Assert.Equal(84, mtbf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMttrHours_NoCompletedCorrective_ReturnsNull()
|
||||
{
|
||||
var workOrders = new List<WorkOrder>
|
||||
{
|
||||
new() { Type = WorkOrderType.Corrective, Status = WorkOrderStatus.InProgress, CreatedAtUtc = DateTime.UtcNow, MachineId = 1, ScheduledDate = DateOnly.FromDateTime(DateTime.UtcNow) },
|
||||
};
|
||||
|
||||
Assert.Null(KpiCalculator.ComputeMttrHours(workOrders));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMttrHours_AveragesRepairDuration()
|
||||
{
|
||||
var created = new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc);
|
||||
var workOrders = new List<WorkOrder>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = WorkOrderType.Corrective,
|
||||
Status = WorkOrderStatus.Done,
|
||||
MachineId = 1,
|
||||
ScheduledDate = DateOnly.FromDateTime(created),
|
||||
CreatedAtUtc = created,
|
||||
CompletedAtUtc = created.AddHours(4),
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WorkOrderType.Corrective,
|
||||
Status = WorkOrderStatus.Done,
|
||||
MachineId = 1,
|
||||
ScheduledDate = DateOnly.FromDateTime(created),
|
||||
CreatedAtUtc = created,
|
||||
CompletedAtUtc = created.AddHours(8),
|
||||
},
|
||||
};
|
||||
|
||||
Assert.Equal(6, KpiCalculator.ComputeMttrHours(workOrders));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAvailabilityPercent_NullInputs_ReturnsNull()
|
||||
{
|
||||
Assert.Null(KpiCalculator.ComputeAvailabilityPercent(null, 5));
|
||||
Assert.Null(KpiCalculator.ComputeAvailabilityPercent(5, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAvailabilityPercent_ComputesClassicFormula()
|
||||
{
|
||||
// MTBF=90, MTTR=10 -> 90/(90+10) = 90%
|
||||
var availability = KpiCalculator.ComputeAvailabilityPercent(90, 10);
|
||||
|
||||
Assert.Equal(90.0, availability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMaintenanceCost_SumsPartsAndLabor()
|
||||
{
|
||||
var parts = new List<(decimal UnitPrice, double Quantity)>
|
||||
{
|
||||
(10.00m, 3), // 30
|
||||
(5.50m, 2), // 11
|
||||
};
|
||||
|
||||
var cost = KpiCalculator.ComputeMaintenanceCost(parts, laborCost: 100m);
|
||||
|
||||
Assert.Equal(141.00m, cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePreventiveComplianceRatePercent_NoClosedOrders_ReturnsNull()
|
||||
{
|
||||
var workOrders = new List<WorkOrder>
|
||||
{
|
||||
new() { Type = WorkOrderType.Preventive, Status = WorkOrderStatus.ToDo, MachineId = 1, ScheduledDate = new DateOnly(2026, 1, 1), CreatedAtUtc = DateTime.UtcNow },
|
||||
};
|
||||
|
||||
Assert.Null(KpiCalculator.ComputePreventiveComplianceRatePercent(workOrders));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePreventiveComplianceRatePercent_CountsOnTimeVsLate()
|
||||
{
|
||||
var workOrders = new List<WorkOrder>
|
||||
{
|
||||
// Clôturé avant échéance -> dans les temps
|
||||
new()
|
||||
{
|
||||
Type = WorkOrderType.Preventive, Status = WorkOrderStatus.Done, MachineId = 1,
|
||||
ScheduledDate = new DateOnly(2026, 1, 10), CreatedAtUtc = DateTime.UtcNow,
|
||||
CompletedAtUtc = new DateTime(2026, 1, 9),
|
||||
},
|
||||
// Clôturé après échéance -> en retard
|
||||
new()
|
||||
{
|
||||
Type = WorkOrderType.Preventive, Status = WorkOrderStatus.Done, MachineId = 1,
|
||||
ScheduledDate = new DateOnly(2026, 1, 10), CreatedAtUtc = DateTime.UtcNow,
|
||||
CompletedAtUtc = new DateTime(2026, 1, 15),
|
||||
},
|
||||
// Curatif : ignoré du calcul
|
||||
new()
|
||||
{
|
||||
Type = WorkOrderType.Corrective, Status = WorkOrderStatus.Done, MachineId = 1,
|
||||
ScheduledDate = new DateOnly(2026, 1, 10), CreatedAtUtc = DateTime.UtcNow,
|
||||
CompletedAtUtc = new DateTime(2026, 1, 20),
|
||||
},
|
||||
};
|
||||
|
||||
var rate = KpiCalculator.ComputePreventiveComplianceRatePercent(workOrders);
|
||||
|
||||
Assert.Equal(50.0, rate);
|
||||
}
|
||||
}
|
||||
131
tests/App.Core.Tests/Licensing/LicenseServiceTests.cs
Normal file
131
tests/App.Core.Tests/Licensing/LicenseServiceTests.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json;
|
||||
using App.Core.Interfaces.Licensing;
|
||||
using App.Core.Services.Licensing;
|
||||
|
||||
namespace App.Core.Tests.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Couvre uniquement le flux "essai" de LicenseService (pas de licence installée) : c'est la
|
||||
/// partie testable sans dépendre de la vraie clé publique Ed25519, qui n'est pas encore générée
|
||||
/// (voir Ed25519PublicKeyProvider). Le flux "licence installée" (ActivateLicense / licence déjà
|
||||
/// présente) sera couvert une fois LicenseGenerator exécuté pour de vrai et la clé embarquée.
|
||||
/// </summary>
|
||||
public class LicenseServiceTests
|
||||
{
|
||||
private sealed class FakeHardwareIdProvider(HardwareId hardwareId) : IHardwareIdProvider
|
||||
{
|
||||
public HardwareId GetCurrent() => hardwareId;
|
||||
}
|
||||
|
||||
private sealed class FakeTrialStateStore : ITrialStateStore
|
||||
{
|
||||
public TrialState? Stored { get; private set; }
|
||||
|
||||
public TrialState? Load() => Stored;
|
||||
|
||||
public void Save(TrialState state) => Stored = state;
|
||||
}
|
||||
|
||||
private sealed class FakeLicenseFileRepository : ILicenseFileRepository
|
||||
{
|
||||
public LicenseFile? Installed { get; set; }
|
||||
|
||||
public LicenseFile? LoadInstalled() => Installed;
|
||||
|
||||
public void Install(LicenseFile license) => Installed = license;
|
||||
}
|
||||
|
||||
private sealed class CorruptedLicenseFileRepository : ILicenseFileRepository
|
||||
{
|
||||
public LicenseFile? LoadInstalled() => throw new JsonException("fichier .lic corrompu (test)");
|
||||
|
||||
public void Install(LicenseFile license) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static readonly HardwareId TestHardware = new("MB-TEST", "DISK-TEST");
|
||||
|
||||
private static LicenseService CreateService(FakeTrialStateStore trialStore, ILicenseFileRepository? licenseRepo = null) =>
|
||||
new(new FakeHardwareIdProvider(TestHardware), trialStore, licenseRepo ?? new FakeLicenseFileRepository());
|
||||
|
||||
[Fact]
|
||||
public void CheckLicenseStatus_FirstLaunch_CreatesTrialAndReturnsFullDuration()
|
||||
{
|
||||
var trialStore = new FakeTrialStateStore();
|
||||
var service = CreateService(trialStore);
|
||||
var now = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var result = service.CheckLicenseStatus(now);
|
||||
|
||||
Assert.Equal(LicenseStatus.TrialActive, result.Status);
|
||||
Assert.Equal(TrialState.TrialDurationDays, result.TrialDaysRemaining);
|
||||
Assert.NotNull(trialStore.Stored);
|
||||
Assert.Equal(now, trialStore.Stored!.InstallDateUtc);
|
||||
Assert.Equal(TestHardware.ComputeHash(), trialStore.Stored.HardwareIdHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLicenseStatus_SubsequentLaunch_ReturnsRemainingDaysAndUpdatesLastRun()
|
||||
{
|
||||
var trialStore = new FakeTrialStateStore();
|
||||
var service = CreateService(trialStore);
|
||||
var installDate = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc);
|
||||
service.CheckLicenseStatus(installDate);
|
||||
|
||||
var result = service.CheckLicenseStatus(installDate.AddDays(10));
|
||||
|
||||
Assert.Equal(LicenseStatus.TrialActive, result.Status);
|
||||
Assert.Equal(20, result.TrialDaysRemaining);
|
||||
Assert.Equal(installDate.AddDays(10), trialStore.Stored!.LastRunTimestampUtc);
|
||||
Assert.Equal(installDate, trialStore.Stored.InstallDateUtc); // date d'installation jamais modifiée
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLicenseStatus_After30Days_ReturnsTrialExpired()
|
||||
{
|
||||
var trialStore = new FakeTrialStateStore();
|
||||
var service = CreateService(trialStore);
|
||||
var installDate = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc);
|
||||
service.CheckLicenseStatus(installDate);
|
||||
|
||||
var result = service.CheckLicenseStatus(installDate.AddDays(31));
|
||||
|
||||
Assert.Equal(LicenseStatus.TrialExpired, result.Status);
|
||||
Assert.Equal(0, result.TrialDaysRemaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLicenseStatus_ClockRolledBack_ReturnsClockTamperDetected()
|
||||
{
|
||||
var trialStore = new FakeTrialStateStore();
|
||||
var service = CreateService(trialStore);
|
||||
var installDate = new DateTime(2026, 1, 15, 9, 0, 0, DateTimeKind.Utc);
|
||||
service.CheckLicenseStatus(installDate);
|
||||
|
||||
// L'utilisateur recule l'horloge système de 5 jours pour tenter de prolonger l'essai.
|
||||
var result = service.CheckLicenseStatus(installDate.AddDays(-5));
|
||||
|
||||
Assert.Equal(LicenseStatus.ClockTamperDetected, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCurrentHardwareId_DelegatesToHardwareIdProvider()
|
||||
{
|
||||
var service = CreateService(new FakeTrialStateStore());
|
||||
|
||||
Assert.Equal(TestHardware, service.GetCurrentHardwareId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLicenseStatus_CorruptedLicenseFile_ReturnsInvalidRatherThanFallingBackToTrial()
|
||||
{
|
||||
var trialStore = new FakeTrialStateStore();
|
||||
var service = CreateService(trialStore, new CorruptedLicenseFileRepository());
|
||||
|
||||
var result = service.CheckLicenseStatus(DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(LicenseStatus.InvalidSignature, result.Status);
|
||||
// Ne doit surtout pas avoir créé un nouvel essai : corrompre le fichier ne doit jamais
|
||||
// redonner 30 jours gratuits.
|
||||
Assert.Null(trialStore.Stored);
|
||||
}
|
||||
}
|
||||
89
tests/App.Core.Tests/Licensing/LicenseValidatorTests.cs
Normal file
89
tests/App.Core.Tests/Licensing/LicenseValidatorTests.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using App.Core.Services.Licensing;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Security;
|
||||
|
||||
namespace App.Core.Tests.Licensing;
|
||||
|
||||
public class LicenseValidatorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Génère une paire de clés de TEST (jamais la clé de production) et signe une licence
|
||||
/// avec, exactement comme le fait LicenseGenerator. Permet de tester LicenseValidator sans
|
||||
/// dépendre de la clé publique embarquée dans Ed25519PublicKeyProvider.
|
||||
/// </summary>
|
||||
private static (byte[] PublicKey, LicenseFile SignedLicense) CreateSignedLicense(
|
||||
string companyName = "ACME Industries",
|
||||
string motherboardUuid = "MB-1234",
|
||||
string diskSerial = "DISK-5678",
|
||||
int maxUsers = 5,
|
||||
int maxMachines = 50)
|
||||
{
|
||||
var generator = new Ed25519KeyPairGenerator();
|
||||
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom()));
|
||||
var keyPair = generator.GenerateKeyPair();
|
||||
var privateKey = (Ed25519PrivateKeyParameters)keyPair.Private;
|
||||
var publicKey = (Ed25519PublicKeyParameters)keyPair.Public;
|
||||
|
||||
var unsigned = new LicenseFile(
|
||||
companyName, motherboardUuid, diskSerial,
|
||||
new DateOnly(2026, 1, 1), null, maxUsers, maxMachines, Signature: string.Empty);
|
||||
|
||||
var canonicalBytes = LicenseFileCanonicalSerializer.GetCanonicalBytes(unsigned);
|
||||
var signer = new Ed25519Signer();
|
||||
signer.Init(forSigning: true, privateKey);
|
||||
signer.BlockUpdate(canonicalBytes, 0, canonicalBytes.Length);
|
||||
var signature = Convert.ToBase64String(signer.GenerateSignature());
|
||||
|
||||
return (publicKey.GetEncoded(), unsigned with { Signature = signature });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifySignature_ValidLicense_ReturnsTrue()
|
||||
{
|
||||
var (publicKey, license) = CreateSignedLicense();
|
||||
|
||||
Assert.True(LicenseValidator.VerifySignature(license, publicKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifySignature_TamperedField_ReturnsFalse()
|
||||
{
|
||||
var (publicKey, license) = CreateSignedLicense();
|
||||
var tampered = license with { MaxUsers = 9999 };
|
||||
|
||||
Assert.False(LicenseValidator.VerifySignature(tampered, publicKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifySignature_WrongPublicKey_ReturnsFalse()
|
||||
{
|
||||
var (_, license) = CreateSignedLicense();
|
||||
var (otherPublicKey, _) = CreateSignedLicense();
|
||||
|
||||
Assert.False(LicenseValidator.VerifySignature(license, otherPublicKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifySignature_CorruptedBase64Signature_ReturnsFalseWithoutThrowing()
|
||||
{
|
||||
var (publicKey, license) = CreateSignedLicense();
|
||||
var corrupted = license with { Signature = "pas-du-base64-valide!!" };
|
||||
|
||||
Assert.False(LicenseValidator.VerifySignature(corrupted, publicKey));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MB-1234", "DISK-5678", true)]
|
||||
[InlineData("mb-1234", "disk-5678", true)] // comparaison insensible à la casse
|
||||
[InlineData("MB-9999", "DISK-5678", false)]
|
||||
[InlineData("MB-1234", "DISK-0000", false)]
|
||||
public void MatchesHardware_RequiresBothIdentifiersToMatch(string motherboard, string disk, bool expected)
|
||||
{
|
||||
var (_, license) = CreateSignedLicense(motherboardUuid: "MB-1234", diskSerial: "DISK-5678");
|
||||
var current = new HardwareId(motherboard, disk);
|
||||
|
||||
Assert.Equal(expected, LicenseValidator.MatchesHardware(license, current));
|
||||
}
|
||||
}
|
||||
55
tests/App.Core.Tests/Licensing/TrialStateTests.cs
Normal file
55
tests/App.Core.Tests/Licensing/TrialStateTests.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using App.Core.Services.Licensing;
|
||||
|
||||
namespace App.Core.Tests.Licensing;
|
||||
|
||||
public class TrialStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetDaysRemaining_OnInstallDay_Returns30()
|
||||
{
|
||||
var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var state = new TrialState(installDate, "hash", installDate);
|
||||
|
||||
Assert.Equal(30, state.GetDaysRemaining(installDate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDaysRemaining_AfterFifteenDays_Returns15()
|
||||
{
|
||||
var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var state = new TrialState(installDate, "hash", installDate);
|
||||
|
||||
Assert.Equal(15, state.GetDaysRemaining(installDate.AddDays(15)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDaysRemaining_NeverGoesNegative()
|
||||
{
|
||||
var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var state = new TrialState(installDate, "hash", installDate);
|
||||
|
||||
Assert.Equal(0, state.GetDaysRemaining(installDate.AddDays(90)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_ExactlyOnDay30_IsTrue()
|
||||
{
|
||||
var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var state = new TrialState(installDate, "hash", installDate);
|
||||
|
||||
Assert.True(state.IsExpired(installDate.AddDays(30)));
|
||||
Assert.False(state.IsExpired(installDate.AddDays(29)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithUpdatedLastRun_DoesNotChangeInstallDate()
|
||||
{
|
||||
var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var state = new TrialState(installDate, "hash", installDate);
|
||||
|
||||
var updated = state.WithUpdatedLastRun(installDate.AddDays(5));
|
||||
|
||||
Assert.Equal(installDate, updated.InstallDateUtc);
|
||||
Assert.Equal(installDate.AddDays(5), updated.LastRunTimestampUtc);
|
||||
}
|
||||
}
|
||||
174
tests/App.Core.Tests/Maintenance/FrequencyCalculatorTests.cs
Normal file
174
tests/App.Core.Tests/Maintenance/FrequencyCalculatorTests.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using App.Core.Entities.Maintenance;
|
||||
using App.Core.Services.Maintenance;
|
||||
|
||||
namespace App.Core.Tests.Maintenance;
|
||||
|
||||
public class FrequencyCalculatorTests
|
||||
{
|
||||
private static MaintenancePlan CreatePlan(FrequencyType type, DateOnly startDate) => new()
|
||||
{
|
||||
MachineId = 1,
|
||||
Name = "Test",
|
||||
FrequencyType = type,
|
||||
StartDate = startDate,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextDueDate_Daily_ReturnsAnchorPlusOneDay()
|
||||
{
|
||||
var plan = CreatePlan(FrequencyType.Daily, new DateOnly(2026, 3, 10));
|
||||
|
||||
var next = FrequencyCalculator.ComputeNextDueDate(plan);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 3, 11), next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextDueDate_UsesLastExecutionDate_WhenPlanAlreadyRanOnce()
|
||||
{
|
||||
var plan = CreatePlan(FrequencyType.Daily, new DateOnly(2026, 1, 1));
|
||||
plan.LastExecutionDateUtc = new DateOnly(2026, 3, 10);
|
||||
|
||||
var next = FrequencyCalculator.ComputeNextDueDate(plan);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 3, 11), next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextDueDate_Weekly_ReturnsNextConfiguredWeekday()
|
||||
{
|
||||
// 2026-03-10 est un mardi. Gamme hebdomadaire calée sur le lundi (1).
|
||||
var plan = CreatePlan(FrequencyType.Weekly, new DateOnly(2026, 3, 10));
|
||||
plan.DayOfWeek = (int)DayOfWeek.Monday;
|
||||
|
||||
var next = FrequencyCalculator.ComputeNextDueDate(plan);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 3, 16), next); // premier lundi après le mardi 10/03
|
||||
Assert.Equal(DayOfWeek.Monday, next.DayOfWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextDueDate_Weekly_MissingDayOfWeek_Throws()
|
||||
{
|
||||
var plan = CreatePlan(FrequencyType.Weekly, new DateOnly(2026, 3, 10));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => FrequencyCalculator.ComputeNextDueDate(plan));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextDueDate_Monthly_ClampsToLastDayOfShorterMonth()
|
||||
{
|
||||
// Ancre en janvier, jour du mois = 31 : février n'a pas 31 jours -> clampé au 28/29.
|
||||
var plan = CreatePlan(FrequencyType.Monthly, new DateOnly(2026, 1, 15));
|
||||
plan.DayOfMonth = 31;
|
||||
|
||||
var next = FrequencyCalculator.ComputeNextDueDate(plan);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 2, 28), next); // 2026 n'est pas bissextile
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextDueDate_MonthlyRule_FirstMonday()
|
||||
{
|
||||
// Ancre en janvier -> cible février 2024. Le 1er lundi de février 2024 est le 05/02.
|
||||
var plan = CreatePlan(FrequencyType.MonthlyRule, new DateOnly(2024, 1, 10));
|
||||
plan.MonthlyRuleOccurrence = 1;
|
||||
plan.MonthlyRuleWeekday = (int)DayOfWeek.Monday;
|
||||
|
||||
var next = FrequencyCalculator.ComputeNextDueDate(plan);
|
||||
|
||||
Assert.Equal(new DateOnly(2024, 2, 5), next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextDueDate_MonthlyRule_LastFriday()
|
||||
{
|
||||
// Cible février 2024 (bissextile). Le dernier vendredi de février 2024 est le 23/02.
|
||||
var plan = CreatePlan(FrequencyType.MonthlyRule, new DateOnly(2024, 1, 10));
|
||||
plan.MonthlyRuleOccurrence = -1;
|
||||
plan.MonthlyRuleWeekday = (int)DayOfWeek.Friday;
|
||||
|
||||
var next = FrequencyCalculator.ComputeNextDueDate(plan);
|
||||
|
||||
Assert.Equal(new DateOnly(2024, 2, 23), next);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FrequencyType.Bimonthly, 2)]
|
||||
[InlineData(FrequencyType.Quarterly, 3)]
|
||||
[InlineData(FrequencyType.Semiannual, 6)]
|
||||
[InlineData(FrequencyType.Annual, 12)]
|
||||
public void ComputeNextDueDate_FixedMonthIntervals_AddsExpectedMonths(FrequencyType type, int months)
|
||||
{
|
||||
var anchor = new DateOnly(2026, 3, 10);
|
||||
var plan = CreatePlan(type, anchor);
|
||||
|
||||
var next = FrequencyCalculator.ComputeNextDueDate(plan);
|
||||
|
||||
Assert.Equal(anchor.AddMonths(months), next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextDueDate_CustomDays_AddsConfiguredInterval()
|
||||
{
|
||||
var plan = CreatePlan(FrequencyType.CustomDays, new DateOnly(2026, 3, 10));
|
||||
plan.CustomIntervalDays = 45;
|
||||
|
||||
var next = FrequencyCalculator.ComputeNextDueDate(plan);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 4, 24), next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextDueDate_CounterBased_ThrowsNotSupported()
|
||||
{
|
||||
var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10));
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => FrequencyCalculator.ComputeNextDueDate(plan));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextCounterThreshold_NoPriorExecution_StartsFromZero()
|
||||
{
|
||||
var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10));
|
||||
plan.CounterThreshold = 500;
|
||||
|
||||
var threshold = FrequencyCalculator.ComputeNextCounterThreshold(plan);
|
||||
|
||||
Assert.Equal(500, threshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextCounterThreshold_AddsToLastExecutionValue()
|
||||
{
|
||||
var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10));
|
||||
plan.CounterThreshold = 500;
|
||||
plan.LastExecutionCounterValue = 1200;
|
||||
|
||||
var threshold = FrequencyCalculator.ComputeNextCounterThreshold(plan);
|
||||
|
||||
Assert.Equal(1700, threshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCounterWithinLeadWindow_TrueOncePercentReached()
|
||||
{
|
||||
var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10));
|
||||
plan.CounterThreshold = 100;
|
||||
plan.LastExecutionCounterValue = 1000;
|
||||
plan.LeadTimePercentForCounter = 90;
|
||||
|
||||
Assert.False(FrequencyCalculator.IsCounterWithinLeadWindow(plan, currentCounterValue: 1085, defaultLeadTimePercent: 80));
|
||||
Assert.True(FrequencyCalculator.IsCounterWithinLeadWindow(plan, currentCounterValue: 1090, defaultLeadTimePercent: 80));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCounterWithinLeadWindow_UsesDefaultPercent_WhenPlanDoesNotOverride()
|
||||
{
|
||||
var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10));
|
||||
plan.CounterThreshold = 100;
|
||||
|
||||
Assert.True(FrequencyCalculator.IsCounterWithinLeadWindow(plan, currentCounterValue: 81, defaultLeadTimePercent: 80));
|
||||
Assert.False(FrequencyCalculator.IsCounterWithinLeadWindow(plan, currentCounterValue: 79, defaultLeadTimePercent: 80));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user