2026-04-24 10:45:44 +02:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 11:34:54 +02:00
|
|
|
[Fact]
|
|
|
|
|
public void Export_ForMonthWhereSecondDayIsWeekend_OnlyHighlightsWeekendAndHolidayDays()
|
|
|
|
|
{
|
|
|
|
|
var exporter = new MonthlyTimesheetExcelExporter();
|
|
|
|
|
var timesheet = CreateTimesheet(new DateOnly(2026, 5, 1), new HashSet<DateOnly>
|
|
|
|
|
{
|
|
|
|
|
new(2026, 5, 4)
|
|
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
var headerRegularFill = GetFillSignature(worksheet.Cell("C1"));
|
|
|
|
|
var headerSaturdayFill = GetFillSignature(worksheet.Cell("D1"));
|
|
|
|
|
var headerSundayFill = GetFillSignature(worksheet.Cell("E1"));
|
|
|
|
|
var headerHolidayFill = GetFillSignature(worksheet.Cell("F1"));
|
|
|
|
|
var headerNextWeekdayFill = GetFillSignature(worksheet.Cell("G1"));
|
|
|
|
|
|
|
|
|
|
Assert.NotEqual(headerRegularFill, headerSaturdayFill);
|
|
|
|
|
Assert.NotEqual(headerRegularFill, headerSundayFill);
|
|
|
|
|
Assert.Equal(headerSundayFill, headerHolidayFill);
|
|
|
|
|
Assert.Equal(headerRegularFill, headerNextWeekdayFill);
|
|
|
|
|
|
|
|
|
|
var bodyRegularFill = GetFillSignature(worksheet.Cell("C4"));
|
|
|
|
|
var bodySaturdayFill = GetFillSignature(worksheet.Cell("D4"));
|
|
|
|
|
var bodyHolidayFill = GetFillSignature(worksheet.Cell("F4"));
|
|
|
|
|
var bodyNextWeekdayFill = GetFillSignature(worksheet.Cell("G4"));
|
|
|
|
|
|
|
|
|
|
Assert.NotEqual(bodyRegularFill, bodySaturdayFill);
|
|
|
|
|
Assert.Equal(bodySaturdayFill, bodyHolidayFill);
|
|
|
|
|
Assert.Equal(bodyRegularFill, bodyNextWeekdayFill);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 10:45:44 +02:00
|
|
|
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.");
|
|
|
|
|
}
|
2026-05-20 11:34:54 +02:00
|
|
|
|
|
|
|
|
private static string GetFillSignature(IXLCell cell)
|
|
|
|
|
{
|
|
|
|
|
var fill = cell.Style.Fill;
|
|
|
|
|
|
|
|
|
|
return string.Join(
|
|
|
|
|
"|",
|
|
|
|
|
fill.PatternType,
|
|
|
|
|
DescribeColor(fill.BackgroundColor),
|
|
|
|
|
DescribeColor(fill.PatternColor));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-24 10:45:44 +02:00
|
|
|
}
|