feat: implement Excel export functionality for monthly timesheets with template support

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Marco 2026-04-24 10:45:44 +02:00
commit e872fe200b
13 changed files with 584 additions and 0 deletions

View file

@ -0,0 +1,119 @@
using ClosedXML.Excel;
using WorkTracker.Domain;
using WorkTracker.Services.Exports;
using Xunit;
namespace WorkTracker.Tests;
public sealed class MonthlyTimesheetExcelExporterTests
{
[Fact]
public void Export_ForApril2026EmptyTimesheet_MatchesExpectedWorkbook()
{
var exporter = new MonthlyTimesheetExcelExporter();
var timesheet = CreateTimesheet(new DateOnly(2026, 4, 1), new HashSet<DateOnly>
{
new(2026, 4, 6),
new(2026, 4, 25)
});
var templatePath = GetTemplatePath();
using var templateStream = File.OpenRead(templatePath);
var workbookBytes = exporter.Export(timesheet, templateStream);
WorkbookAssert.Equivalent(GetExpectedWorkbookPath(), workbookBytes);
}
[Fact]
public void Export_ForThirtyOneDayMonth_ShiftsTotalColumnAfterLastDay()
{
var exporter = new MonthlyTimesheetExcelExporter();
var timesheet = CreateTimesheet(new DateOnly(2026, 5, 1), new HashSet<DateOnly>());
var templatePath = GetTemplatePath();
using var templateStream = File.OpenRead(templatePath);
var workbookBytes = exporter.Export(timesheet, templateStream);
using var workbook = new XLWorkbook(new MemoryStream(workbookBytes));
var worksheet = workbook.Worksheet(1);
Assert.Equal(31d, worksheet.Cell("AG1").GetDouble());
Assert.Equal("SUM(C2:AG2)", worksheet.Cell("AH2").FormulaA1);
Assert.Equal("TOTALE", worksheet.Cell("AH1").GetString());
}
private static MonthlyTimesheetModel CreateTimesheet(DateOnly monthStart, ISet<DateOnly> holidays)
{
var lastDay = monthStart.AddMonths(1).AddDays(-1);
var days = new List<MonthlyTimesheetDayModel>();
for (var date = monthStart; date <= lastDay; date = date.AddDays(1))
{
days.Add(new MonthlyTimesheetDayModel
{
Date = date,
IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
IsHoliday = holidays.Contains(date)
});
}
return new MonthlyTimesheetModel
{
Year = monthStart.Year,
Month = monthStart.Month,
Days = days,
Rows =
[
CreateRow("office", days.Count),
CreateRow("home", days.Count),
CreateRow("overtime", days.Count),
CreateRow("weekend", days.Count),
CreateRow("night", days.Count),
CreateRow("vacation", days.Count),
CreateRow("permit", days.Count),
CreateRow("compensatory-rest", days.Count),
CreateRow("sick", days.Count),
CreateRow("holiday", days.Count)
]
};
}
private static MonthlyTimesheetRowModel CreateRow(string key, int dayCount)
{
return new MonthlyTimesheetRowModel
{
Key = key,
DailyValues = Enumerable.Repeat<decimal?>(null, dayCount).ToList()
};
}
private static string GetExpectedWorkbookPath()
{
var repositoryRoot = FindRepositoryRoot();
var candidate = Path.Combine(repositoryRoot, "tests", "WorkTracker.Tests", "Expected", "monthly-timesheet-2026-04-empty.expected.xlsx");
return File.Exists(candidate)
? candidate
: GetTemplatePath();
}
private static string GetTemplatePath()
{
var repositoryRoot = FindRepositoryRoot();
return Path.Combine(repositoryRoot, "Templates", "monthly-timesheet-template.xlsx");
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "WorkTracker.sln")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Unable to locate the WorkTracker repository root.");
}
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\WorkTracker.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,90 @@
using ClosedXML.Excel;
using Xunit;
namespace WorkTracker.Tests;
internal static class WorkbookAssert
{
public static void Equivalent(string expectedPath, byte[] actualContent)
{
using var expectedWorkbook = new XLWorkbook(expectedPath);
using var actualStream = new MemoryStream(actualContent);
using var actualWorkbook = new XLWorkbook(actualStream);
var expectedWorksheet = expectedWorkbook.Worksheet(1);
var actualWorksheet = actualWorkbook.Worksheet(1);
Assert.Equal(expectedWorksheet.Name, actualWorksheet.Name);
Assert.Equal(expectedWorksheet.RangeUsed()?.RangeAddress.ToString(), actualWorksheet.RangeUsed()?.RangeAddress.ToString());
Assert.Equal(expectedWorksheet.MergedRanges.Select(range => range.RangeAddress.ToString()), actualWorksheet.MergedRanges.Select(range => range.RangeAddress.ToString()));
var usedRange = expectedWorksheet.RangeUsed() ?? throw new InvalidOperationException("Expected workbook must have a used range.");
foreach (var column in Enumerable.Range(usedRange.RangeAddress.FirstAddress.ColumnNumber, usedRange.ColumnCount()))
{
Assert.Equal(Math.Round(expectedWorksheet.Column(column).Width, 5), Math.Round(actualWorksheet.Column(column).Width, 5));
}
foreach (var cell in usedRange.Cells())
{
var actualCell = actualWorksheet.Cell(cell.Address.RowNumber, cell.Address.ColumnNumber);
Assert.Equal(GetCellValue(cell), GetCellValue(actualCell));
Assert.Equal(cell.FormulaA1, actualCell.FormulaA1);
if (string.IsNullOrEmpty(cell.FormulaA1))
{
Assert.Equal(cell.DataType, actualCell.DataType);
}
var expectedStyle = DescribeStyle(cell.Style);
var actualStyle = DescribeStyle(actualCell.Style);
Assert.True(expectedStyle == actualStyle, $"Style mismatch at {cell.Address}: expected '{expectedStyle}' actual '{actualStyle}'");
}
}
private static string GetCellValue(IXLCell cell)
{
if (!string.IsNullOrEmpty(cell.FormulaA1))
{
return string.Empty;
}
return cell.Value.ToString();
}
private static string DescribeStyle(IXLStyle style)
{
return string.Join(
"|",
style.NumberFormat.NumberFormatId,
style.NumberFormat.Format,
style.Fill.PatternType,
DescribeColor(style.Fill.BackgroundColor),
DescribeColor(style.Fill.PatternColor),
style.Font.FontName,
style.Font.FontSize,
style.Font.Bold,
style.Alignment.Horizontal,
style.Alignment.Vertical,
style.Alignment.WrapText,
style.Border.LeftBorder,
DescribeColor(style.Border.LeftBorderColor),
style.Border.RightBorder,
DescribeColor(style.Border.RightBorderColor),
style.Border.TopBorder,
DescribeColor(style.Border.TopBorderColor),
style.Border.BottomBorder,
DescribeColor(style.Border.BottomBorderColor),
style.Protection.Locked);
}
private static string DescribeColor(XLColor color)
{
return color.ColorType switch
{
XLColorType.Color => $"rgb:{color.Color.ToArgb()}",
XLColorType.Indexed => $"indexed:{color.Indexed}",
XLColorType.Theme => $"theme:{color.ThemeColor}:{Math.Round(color.ThemeTint, 12)}",
_ => color.ColorType.ToString()
};
}
}