Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build-and-test:
runs-on: windows-latest

steps:
- uses: actions/checkout@v4

- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.x'

- name: Restore
run: dotnet restore

- name: Build
run: dotnet build --no-restore --configuration Release

- name: Test
run: dotnet test --no-build --configuration Release --verbosity normal
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Visual Studio
.vs/
*.user

# Build output
**/bin/
**/obj/

# NuGet
*.nupkg
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
43 changes: 43 additions & 0 deletions CalculateBestDaysOff.Domain/FrenchPublicHolidays.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace CalculateBestDaysOff.Domain;

public static class FrenchPublicHolidays
{
public static IReadOnlyDictionary<DateOnly, string> For(int year)
{
var easter = ComputeEaster(year);
return new Dictionary<DateOnly, string>
{
[new(year, 1, 1)] = "Jour de l'An",
[easter.AddDays(1)] = "Lundi de Pâques",
[new(year, 5, 1)] = "Fête du Travail",
[new(year, 5, 8)] = "Victoire 1945",
[easter.AddDays(39)] = "Ascension",
[easter.AddDays(50)] = "Lundi de Pentecôte",
[new(year, 7, 14)] = "Fête Nationale",
[new(year, 8, 15)] = "Assomption",
[new(year, 11, 1)] = "Toussaint",
[new(year, 11, 11)] = "Armistice",
[new(year, 12, 25)] = "Noël",
};
}

// Algorithme de Meeus/Jones/Butcher
public static DateOnly ComputeEaster(int year)
{
int a = year % 19;
int b = year / 100;
int c = year % 100;
int d = b / 4;
int e = b % 4;
int f = (b + 8) / 25;
int g = (b - f + 1) / 3;
int h = (19 * a + b - d - g + 15) % 30;
int i = c / 4;
int k = c % 4;
int l = (32 + 2 * e + 2 * i - h - k) % 7;
int m = (a + 11 * h + 22 * l) / 451;
int month = (h + l - 7 * m + 114) / 31;
int day = ((h + l - 7 * m + 114) % 31) + 1;
return new DateOnly(year, month, day);
}
}
10 changes: 10 additions & 0 deletions CalculateBestDaysOff.Domain/VacationOpportunity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace CalculateBestDaysOff.Domain;

public sealed record VacationOpportunity(
DateOnly Start,
DateOnly End,
int TotalDaysOff,
int DaysTaken,
double Ratio,
IReadOnlyList<string> HolidaysIncluded,
IReadOnlyList<DateOnly> WorkDaysTaken);
68 changes: 68 additions & 0 deletions CalculateBestDaysOff.Domain/VacationOptimizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
namespace CalculateBestDaysOff.Domain;

public static class VacationOptimizer
{
/// <summary>
/// Trouve toutes les opportunités de congés pour l'année donnée.
/// Un fenêtre [start, end] est valide si elle contient entre 1 et maxDaysTaken jours ouvrés
/// et est maximale (ne peut pas être étendue sans ajouter un jour ouvré).
/// </summary>
public static IReadOnlyList<VacationOpportunity> FindBest(int year, int maxDaysTaken)
{
var holidays = FrenchPublicHolidays.For(year);
var jan1 = new DateOnly(year, 1, 1);
var dec31 = new DateOnly(year, 12, 31);

bool IsFree(DateOnly d) =>
d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
|| holidays.ContainsKey(d);

var opportunities = new List<VacationOpportunity>();

for (var start = jan1; start <= dec31; start = start.AddDays(1))
{
// On ne démarre une fenêtre qu'au début d'un bloc libre (ou en Jan 1)
// pour garantir que la fenêtre est maximale à gauche.
if (start != jan1 && IsFree(start.AddDays(-1)))
continue;

int workCount = 0;
for (var end = start; end <= dec31; end = end.AddDays(1))
{
if (!IsFree(end)) workCount++;
if (workCount > maxDaysTaken) break;

// La fenêtre est maximale à droite si le lendemain est ouvré (ou fin d'année)
bool rightBlocked = end == dec31 || !IsFree(end.AddDays(1));
if (rightBlocked && workCount > 0)
{
int total = end.DayNumber - start.DayNumber + 1;
var holidaysInWindow = EnumerateDays(start, end)
.Where(holidays.ContainsKey)
.Select(d => holidays[d])
.ToList();

var workDaysInWindow = EnumerateDays(start, end)
.Where(d => !IsFree(d))
.ToList();

opportunities.Add(new VacationOpportunity(
start, end, total, workCount,
(double)total / workCount,
holidaysInWindow,
workDaysInWindow));
}
}
}

return [.. opportunities
.OrderByDescending(o => o.Ratio)
.ThenByDescending(o => o.TotalDaysOff)];
}

private static IEnumerable<DateOnly> EnumerateDays(DateOnly start, DateOnly end)
{
for (var d = start; d <= end; d = d.AddDays(1))
yield return d;
}
}
25 changes: 25 additions & 0 deletions CalculateBestDaysOff.Tests/CalculateBestDaysOff.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CalculateBestDaysOff.Domain\CalculateBestDaysOff.Domain.csproj" />
</ItemGroup>

</Project>
50 changes: 50 additions & 0 deletions CalculateBestDaysOff.Tests/FrenchPublicHolidaysTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using CalculateBestDaysOff.Domain;

namespace CalculateBestDaysOff.Tests;

public class FrenchPublicHolidaysTests
{
[Theory]
[InlineData(2024, 3, 31)] // Pâques 2024
[InlineData(2025, 4, 20)] // Pâques 2025
[InlineData(2026, 4, 5)] // Pâques 2026
public void ComputeEaster_ReturnsKnownDate(int year, int month, int day)
{
var easter = FrenchPublicHolidays.ComputeEaster(year);
Assert.Equal(new DateOnly(year, month, day), easter);
}

[Fact]
public void For_Returns11Holidays()
{
var holidays = FrenchPublicHolidays.For(2025);
Assert.Equal(11, holidays.Count);
}

[Theory]
[InlineData(2025, 1, 1, "Jour de l'An")]
[InlineData(2025, 5, 1, "Fête du Travail")]
[InlineData(2025, 5, 8, "Victoire 1945")]
[InlineData(2025, 7, 14, "Fête Nationale")]
[InlineData(2025, 8, 15, "Assomption")]
[InlineData(2025, 11, 1, "Toussaint")]
[InlineData(2025, 11, 11,"Armistice")]
[InlineData(2025, 12, 25,"Noël")]
public void For_ContainsFixedHolidays(int year, int month, int day, string name)
{
var holidays = FrenchPublicHolidays.For(year);
var date = new DateOnly(year, month, day);
Assert.True(holidays.ContainsKey(date));
Assert.Equal(name, holidays[date]);
}

[Fact]
public void For_2025_EasterBasedHolidaysAreCorrect()
{
// Pâques 2025 = 20 avril
var holidays = FrenchPublicHolidays.For(2025);
Assert.True(holidays.ContainsKey(new DateOnly(2025, 4, 21))); // Lundi de Pâques
Assert.True(holidays.ContainsKey(new DateOnly(2025, 5, 29))); // Ascension (+39)
Assert.True(holidays.ContainsKey(new DateOnly(2025, 6, 9))); // Lundi de Pentecôte (+50)
}
}
73 changes: 73 additions & 0 deletions CalculateBestDaysOff.Tests/VacationOptimizerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using CalculateBestDaysOff.Domain;

namespace CalculateBestDaysOff.Tests;

public class VacationOptimizerTests
{
[Fact]
public void FindBest_ReturnsNonEmptyList()
{
var results = VacationOptimizer.FindBest(2025, 5);
Assert.NotEmpty(results);
}

[Fact]
public void FindBest_ResultsAreSortedByRatioDescending()
{
var results = VacationOptimizer.FindBest(2025, 5);
for (int i = 0; i < results.Count - 1; i++)
Assert.True(results[i].Ratio >= results[i + 1].Ratio,
$"Résultat {i} (ratio {results[i].Ratio}) < résultat {i + 1} (ratio {results[i + 1].Ratio})");
}

[Fact]
public void FindBest_NoDaysTakenExceedsMax()
{
const int max = 3;
var results = VacationOptimizer.FindBest(2025, max);
Assert.All(results, o => Assert.True(o.DaysTaken <= max));
}

[Fact]
public void FindBest_RatioEqualsTotal_DividedBy_Taken()
{
var results = VacationOptimizer.FindBest(2025, 5);
Assert.All(results, o =>
Assert.Equal((double)o.TotalDaysOff / o.DaysTaken, o.Ratio, precision: 10));
}

[Fact]
public void FindBest_NoWindowWithZeroDaysTaken()
{
var results = VacationOptimizer.FindBest(2025, 5);
Assert.All(results, o => Assert.True(o.DaysTaken >= 1));
}

[Fact]
public void FindBest_2025_BestOpportunityAroundMay1()
{
// 1er mai 2025 = jeudi (férié), 2 mai = vendredi (ouvré), 3-4 mai = week-end
// Poser 1 jour (2 mai) → 4 jours off (1-4 mai) → ratio 4
// Pâques 2025 = 20 avril (dim), Lundi de Pâques = 21 avril
// Le week-end 19-20 + lundi férié 21 = 3 jours free
// Si on ajoute 22 avril (mardi, ouvré) → 4 jours (19-22), ratio 4
var results = VacationOptimizer.FindBest(2025, 1);
Assert.True(results[0].Ratio >= 4.0,
$"Meilleur ratio attendu >= 4, obtenu {results[0].Ratio}");
}

[Fact]
public void FindBest_WindowEndIsAlwaysAfterOrEqualStart()
{
var results = VacationOptimizer.FindBest(2025, 10);
Assert.All(results, o => Assert.True(o.End >= o.Start));
}

[Fact]
public void FindBest_TotalDaysOffMatchesDateRange()
{
var results = VacationOptimizer.FindBest(2025, 5);
Assert.All(results, o =>
Assert.Equal(o.End.DayNumber - o.Start.DayNumber + 1, o.TotalDaysOff));
}
}
6 changes: 6 additions & 0 deletions CalculateBestDaysOff.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Solution>
<Project Path="CalculateBestDaysOff.Domain/CalculateBestDaysOff.Domain.csproj" />
<Project Path="CalculateBestDaysOff.Tests/CalculateBestDaysOff.Tests.csproj" />
<Project Path="CalculateBestDaysOff/CalculateBestDaysOff.csproj" />
<Project Path="Conges/Conges.csproj" />
</Solution>
18 changes: 18 additions & 0 deletions CalculateBestDaysOff/CalculateBestDaysOff.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.54.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CalculateBestDaysOff.Domain\CalculateBestDaysOff.Domain.csproj" />
</ItemGroup>

</Project>
Loading