feat: implement Excel export functionality for monthly timesheets with template support
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
0d5b48b891
commit
e872fe200b
13 changed files with 584 additions and 0 deletions
Binary file not shown.
119
tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
Normal file
119
tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
21
tests/WorkTracker.Tests/WorkTracker.Tests.csproj
Normal file
21
tests/WorkTracker.Tests/WorkTracker.Tests.csproj
Normal 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>
|
||||
90
tests/WorkTracker.Tests/WorkbookAssert.cs
Normal file
90
tests/WorkTracker.Tests/WorkbookAssert.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue