From e872fe200ba7e66917cf26ee488015578e2bce81 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 24 Apr 2026 10:45:44 +0200 Subject: [PATCH] feat: implement Excel export functionality for monthly timesheets with template support Co-authored-by: Copilot --- Components/Pages/MonthlySummary.razor | 6 + Program.cs | 19 ++ .../IMonthlyTimesheetExcelExportService.cs | 15 ++ .../Exports/IMonthlyTimesheetExcelExporter.cs | 8 + .../MonthlyTimesheetExcelExportService.cs | 33 +++ .../Exports/MonthlyTimesheetExcelExporter.cs | 230 ++++++++++++++++++ Templates/monthly-timesheet-template.xlsx | Bin 0 -> 16180 bytes WorkTracker.csproj | 11 + WorkTracker.sln | 32 +++ .../Timesheet Aprile 2026-filled.xlsx | Bin 0 -> 76032 bytes .../MonthlyTimesheetExcelExporterTests.cs | 119 +++++++++ .../WorkTracker.Tests.csproj | 21 ++ tests/WorkTracker.Tests/WorkbookAssert.cs | 90 +++++++ 13 files changed, 584 insertions(+) create mode 100644 Services/Exports/IMonthlyTimesheetExcelExportService.cs create mode 100644 Services/Exports/IMonthlyTimesheetExcelExporter.cs create mode 100644 Services/Exports/MonthlyTimesheetExcelExportService.cs create mode 100644 Services/Exports/MonthlyTimesheetExcelExporter.cs create mode 100644 Templates/monthly-timesheet-template.xlsx create mode 100644 tests/WorkTracker.Tests/Expected/Timesheet Aprile 2026-filled.xlsx create mode 100644 tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs create mode 100644 tests/WorkTracker.Tests/WorkTracker.Tests.csproj create mode 100644 tests/WorkTracker.Tests/WorkbookAssert.cs diff --git a/Components/Pages/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor index 6debd76..8ebbc97 100644 --- a/Components/Pages/MonthlySummary.razor +++ b/Components/Pages/MonthlySummary.razor @@ -16,6 +16,7 @@

@currentMonth.ToString("MMMM yyyy")

+ Download Excel Yearly Summary @@ -277,6 +278,11 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null) viewMode = mode; } + private string GetExcelDownloadUrl() + { + return $"/api/monthly-timesheet/{currentMonth.Year}/{currentMonth.Month}/excel?includePreview={includePreview.ToString().ToLowerInvariant()}"; + } + private static string GetDayHeader(DateOnly date) { return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture)); diff --git a/Program.cs b/Program.cs index d67f0ef..31e20e5 100644 --- a/Program.cs +++ b/Program.cs @@ -13,6 +13,7 @@ using WorkTracker.Components; using WorkTracker.Configuration; using WorkTracker.Services.Auth; using WorkTracker.Services.Festivities; +using WorkTracker.Services.Exports; using WorkTracker.Services.Settings; using WorkTracker.Services.Storage; using WorkTracker.Services.WorkDays; @@ -74,6 +75,8 @@ builder.Services.AddScoped builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); @@ -245,6 +248,22 @@ app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions +{ + if (month is < 1 or > 12) + { + return Results.BadRequest("Month must be between 1 and 12."); + } + + var file = await exportService.ExportAsync(year, month, includePreview, cancellationToken); + return Results.File(file.Content, file.ContentType, file.FileName); +}).RequireAuthorization(); + // Development-only endpoint to reset the seeded Admin password (protected by secret in URL) if (app.Environment.IsDevelopment()) { diff --git a/Services/Exports/IMonthlyTimesheetExcelExportService.cs b/Services/Exports/IMonthlyTimesheetExcelExportService.cs new file mode 100644 index 0000000..39b6643 --- /dev/null +++ b/Services/Exports/IMonthlyTimesheetExcelExportService.cs @@ -0,0 +1,15 @@ +namespace WorkTracker.Services.Exports; + +public interface IMonthlyTimesheetExcelExportService +{ + Task ExportAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default); +} + +public sealed class MonthlyTimesheetExcelFile +{ + public required string FileName { get; init; } + + public required byte[] Content { get; init; } + + public string ContentType => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; +} \ No newline at end of file diff --git a/Services/Exports/IMonthlyTimesheetExcelExporter.cs b/Services/Exports/IMonthlyTimesheetExcelExporter.cs new file mode 100644 index 0000000..59646b4 --- /dev/null +++ b/Services/Exports/IMonthlyTimesheetExcelExporter.cs @@ -0,0 +1,8 @@ +using WorkTracker.Domain; + +namespace WorkTracker.Services.Exports; + +public interface IMonthlyTimesheetExcelExporter +{ + byte[] Export(MonthlyTimesheetModel timesheet, Stream templateStream); +} \ No newline at end of file diff --git a/Services/Exports/MonthlyTimesheetExcelExportService.cs b/Services/Exports/MonthlyTimesheetExcelExportService.cs new file mode 100644 index 0000000..f4b7c9a --- /dev/null +++ b/Services/Exports/MonthlyTimesheetExcelExportService.cs @@ -0,0 +1,33 @@ +using WorkTracker.Services.WorkDays; + +namespace WorkTracker.Services.Exports; + +public sealed class MonthlyTimesheetExcelExportService( + IWorkDayService workDayService, + IMonthlyTimesheetExcelExporter exporter, + IWebHostEnvironment environment) : IMonthlyTimesheetExcelExportService +{ + private readonly IWorkDayService workDayService = workDayService; + private readonly IMonthlyTimesheetExcelExporter exporter = exporter; + private readonly IWebHostEnvironment environment = environment; + + public async Task ExportAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default) + { + var templatePath = Path.Combine(environment.ContentRootPath, "Templates", "monthly-timesheet-template.xlsx"); + if (!File.Exists(templatePath)) + { + throw new FileNotFoundException("Monthly timesheet template not found.", templatePath); + } + + var timesheet = await workDayService.GetMonthlyTimesheetAsync(year, month, includePreview, cancellationToken); + + await using var templateStream = File.OpenRead(templatePath); + var content = exporter.Export(timesheet, templateStream); + + return new MonthlyTimesheetExcelFile + { + FileName = $"timesheet-{year}-{month:00}.xlsx", + Content = content + }; + } +} \ No newline at end of file diff --git a/Services/Exports/MonthlyTimesheetExcelExporter.cs b/Services/Exports/MonthlyTimesheetExcelExporter.cs new file mode 100644 index 0000000..b6b7036 --- /dev/null +++ b/Services/Exports/MonthlyTimesheetExcelExporter.cs @@ -0,0 +1,230 @@ +using ClosedXML.Excel; +using WorkTracker.Domain; + +namespace WorkTracker.Services.Exports; + +public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExporter +{ + private const int DayHeaderRow = 1; + private const int DayStartColumn = 3; + private const int TemplateDayCount = 30; + + private static readonly IReadOnlyDictionary TimesheetRowMap = new Dictionary(StringComparer.Ordinal) + { + ["office"] = 2, + ["home"] = 4, + ["overtime"] = 5, + ["weekend"] = 6, + ["night"] = 7, + ["vacation"] = 8, + ["permit"] = 9, + ["compensatory-rest"] = 10, + ["sick"] = 11, + ["holiday"] = 12 + }; + + public byte[] Export(MonthlyTimesheetModel timesheet, Stream templateStream) + { + ArgumentNullException.ThrowIfNull(timesheet); + ArgumentNullException.ThrowIfNull(templateStream); + + if (templateStream.CanSeek) + { + templateStream.Position = 0; + } + + using var workbook = new XLWorkbook(templateStream); + workbook.DefinedNames.DeleteAll(); + var worksheet = workbook.Worksheet(1); + var dayCount = timesheet.Days.Count; + + AdjustDayColumns(worksheet, dayCount); + ApplyDayHeaders(worksheet, timesheet.Days); + ApplyRowValues(worksheet, timesheet.Rows, dayCount); + ApplyTotalFormulas(worksheet, dayCount); + + using var output = new MemoryStream(); + workbook.SaveAs(output); + return output.ToArray(); + } + + private static void AdjustDayColumns(IXLWorksheet worksheet, int dayCount) + { + var totalColumn = DayStartColumn + TemplateDayCount; + var delta = dayCount - TemplateDayCount; + + if (delta > 0) + { + worksheet.Column(totalColumn).InsertColumnsBefore(delta); + } + else if (delta < 0) + { + worksheet.Columns(DayStartColumn + dayCount, DayStartColumn + TemplateDayCount - 1).Delete(); + } + } + + private static void ApplyDayHeaders(IXLWorksheet worksheet, IReadOnlyList days) + { + var lastDayColumn = DayStartColumn + days.Count - 1; + + for (var index = 0; index < days.Count; index++) + { + var day = days[index]; + var column = DayStartColumn + index; + + var headerCell = worksheet.Cell(DayHeaderRow, column); + headerCell.Value = day.Date.Day; + ApplyDayStyle(worksheet, headerCell, day, column, lastDayColumn, 1); + + foreach (var rowNumber in TimesheetRowMap.Values) + { + ApplyDayStyle(worksheet, worksheet.Cell(rowNumber, column), day, column, lastDayColumn, rowNumber); + } + } + } + + private static void ApplyRowValues(IXLWorksheet worksheet, IReadOnlyList rows, int dayCount) + { + foreach (var row in rows) + { + if (!TimesheetRowMap.TryGetValue(row.Key, out var worksheetRow)) + { + continue; + } + + for (var dayIndex = 0; dayIndex < dayCount; dayIndex++) + { + var cell = worksheet.Cell(worksheetRow, DayStartColumn + dayIndex); + var value = dayIndex < row.DailyValues.Count ? row.DailyValues[dayIndex] : null; + if (value.HasValue && value.Value > 0m) + { + cell.Value = value.Value; + } + else + { + cell.Clear(XLClearOptions.Contents); + } + } + } + } + + private static void ApplyTotalFormulas(IXLWorksheet worksheet, int dayCount) + { + var lastDayColumn = DayStartColumn + dayCount - 1; + var totalColumn = lastDayColumn + 1; + var firstDayColumnLetter = XLHelper.GetColumnLetterFromNumber(DayStartColumn); + var lastDayColumnLetter = XLHelper.GetColumnLetterFromNumber(lastDayColumn); + + foreach (var rowNumber in TimesheetRowMap.Values) + { + worksheet.Cell(rowNumber, totalColumn).FormulaA1 = $"SUM({firstDayColumnLetter}{rowNumber}:{lastDayColumnLetter}{rowNumber})"; + } + } + + private static void ApplyDayStyle(IXLWorksheet worksheet, IXLCell targetCell, MonthlyTimesheetDayModel day, int column, int lastDayColumn, int rowNumber) + { + var baseCell = worksheet.Cell(GetBaseStyleRowAddress(rowNumber), GetBaseStyleColumn(column, lastDayColumn)); + var baseStyle = baseCell.Style; + + if (!ShouldHighlight(day)) + { + targetCell.Style = baseStyle; + return; + } + + var sampleColumn = GetHighlightSampleColumn(day, rowNumber); + var sampleCell = worksheet.Cell(GetSampleStyleRow(rowNumber), sampleColumn); + var sampleStyle = sampleCell.Style; + + targetCell.Style = sampleStyle; + targetCell.Style.Border.LeftBorder = baseStyle.Border.LeftBorder; + targetCell.Style.Border.LeftBorderColor = baseStyle.Border.LeftBorderColor; + targetCell.Style.Border.RightBorder = baseStyle.Border.RightBorder; + targetCell.Style.Border.RightBorderColor = baseStyle.Border.RightBorderColor; + targetCell.Style.Border.TopBorder = baseStyle.Border.TopBorder; + targetCell.Style.Border.TopBorderColor = baseStyle.Border.TopBorderColor; + targetCell.Style.Border.BottomBorder = baseStyle.Border.BottomBorder; + targetCell.Style.Border.BottomBorderColor = baseStyle.Border.BottomBorderColor; + } + + private static bool ShouldHighlight(MonthlyTimesheetDayModel day) + { + return day.IsWeekend || day.IsHoliday; + } + + private static int GetBaseStyleRowAddress(int rowNumber) + { + return rowNumber switch + { + 1 => 1, + 2 => 2, + 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 => 4, + 12 => 12, + _ => rowNumber + }; + } + + private static int GetSampleStyleRow(int rowNumber) + { + return rowNumber switch + { + 1 => 1, + 2 => 2, + 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 => 4, + 12 => 12, + _ => rowNumber + }; + } + + private static int GetBaseStyleColumn(int column, int lastDayColumn) + { + if (column == DayStartColumn) + { + return DayStartColumn; + } + + if (column == lastDayColumn) + { + return lastDayColumn == DayStartColumn + TemplateDayCount - 1 + ? DayStartColumn + TemplateDayCount - 1 + : DayStartColumn + 1; + } + + return DayStartColumn + 1; + } + + private static int GetHighlightSampleColumn(MonthlyTimesheetDayModel day, int rowNumber) + { + if (rowNumber == 1) + { + if (day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday) + { + return 7; + } + + if (day.Date.DayOfWeek == DayOfWeek.Saturday) + { + return 6; + } + + return 7; + } + + if (rowNumber == 2) + { + if (day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday) + { + return 7; + } + + if (day.Date.DayOfWeek == DayOfWeek.Saturday) + { + return 6; + } + + return 7; + } + + return 6; + } +} \ No newline at end of file diff --git a/Templates/monthly-timesheet-template.xlsx b/Templates/monthly-timesheet-template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..0d9df9daae35e82520492f48ec888b3c1b47d8de GIT binary patch literal 16180 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 literal 0 HcmV?d00001 diff --git a/WorkTracker.csproj b/WorkTracker.csproj index c764034..c24a782 100644 --- a/WorkTracker.csproj +++ b/WorkTracker.csproj @@ -8,15 +8,26 @@ + + + + + + + PreserveNewest + + PreserveNewest + PreserveNewest + diff --git a/WorkTracker.sln b/WorkTracker.sln index 4c0c3b9..b6f3355 100644 --- a/WorkTracker.sln +++ b/WorkTracker.sln @@ -1,23 +1,55 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTracker", "WorkTracker.csproj", "{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTracker.Tests", "tests\WorkTracker.Tests\WorkTracker.Tests.csproj", "{87B6F668-25F6-4F14-B185-176514507ADE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x64.Build.0 = Debug|Any CPU + {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x86.Build.0 = Debug|Any CPU {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|Any CPU.Build.0 = Release|Any CPU + {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x64.ActiveCfg = Release|Any CPU + {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x64.Build.0 = Release|Any CPU + {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x86.ActiveCfg = Release|Any CPU + {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x86.Build.0 = Release|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x64.ActiveCfg = Debug|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x64.Build.0 = Debug|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x86.ActiveCfg = Debug|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x86.Build.0 = Debug|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Release|Any CPU.Build.0 = Release|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Release|x64.ActiveCfg = Release|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Release|x64.Build.0 = Release|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Release|x86.ActiveCfg = Release|Any CPU + {87B6F668-25F6-4F14-B185-176514507ADE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {87B6F668-25F6-4F14-B185-176514507ADE} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9E2849A1-F16A-4322-A66A-A64428230E83} EndGlobalSection diff --git a/tests/WorkTracker.Tests/Expected/Timesheet Aprile 2026-filled.xlsx b/tests/WorkTracker.Tests/Expected/Timesheet Aprile 2026-filled.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..365864557aceb62152ba2425b86abbf187e3a44e GIT binary patch literal 76032 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 + { + 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()); + 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 holidays) + { + var lastDay = monthStart.AddMonths(1).AddDays(-1); + var days = new List(); + 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(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."); + } +} diff --git a/tests/WorkTracker.Tests/WorkTracker.Tests.csproj b/tests/WorkTracker.Tests/WorkTracker.Tests.csproj new file mode 100644 index 0000000..f8039eb --- /dev/null +++ b/tests/WorkTracker.Tests/WorkTracker.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/WorkTracker.Tests/WorkbookAssert.cs b/tests/WorkTracker.Tests/WorkbookAssert.cs new file mode 100644 index 0000000..c7a2772 --- /dev/null +++ b/tests/WorkTracker.Tests/WorkbookAssert.cs @@ -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() + }; + } +}