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 0000000..0d9df9d Binary files /dev/null and b/Templates/monthly-timesheet-template.xlsx differ 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 0000000..3658645 Binary files /dev/null and b/tests/WorkTracker.Tests/Expected/Timesheet Aprile 2026-filled.xlsx differ diff --git a/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs b/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs new file mode 100644 index 0000000..bc55c78 --- /dev/null +++ b/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs @@ -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 + { + 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() + }; + } +}