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
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