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/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index 38d2689..64fca6e 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -1,8 +1,13 @@ @page "/settings" @attribute [Authorize] +@using Microsoft.AspNetCore.Components.Forms +@using WorkTracker.Services.Storage + @inject IAppSettingsService AppSettingsService +@inject IDatabaseBackupService DatabaseBackupService @inject AppThemeState ThemeState +@inject IJSRuntime JS Settings @@ -65,11 +70,60 @@ else } + +
+

Database backup

+

Export the full database as JSON or restore a previously exported JSON backup. Restore replaces the current database only when the backup format version and database schema version are supported.

+ +
+ Export JSON backup + Current database schema version: @DatabaseSchemaVersion +
+ +
+
+

Restore from JSON

+

This overwrites the existing database with the selected backup file.

+ +
+ + + @if (!string.IsNullOrWhiteSpace(selectedBackupFileName)) + { +
Selected file: @selectedBackupFileName
+ } + + +
+ + @if (!string.IsNullOrWhiteSpace(backupStatusMessage)) + { +
@backupStatusMessage
+ } + + @if (!string.IsNullOrWhiteSpace(backupErrorMessage)) + { +
@backupErrorMessage
+ } +
+
+
} @code { + private const long MaxBackupFileSize = 20 * 1024 * 1024; + private AppSettingsDocument? settings; private string? statusMessage; + private string? backupStatusMessage; + private string? backupErrorMessage; + private IBrowserFile? selectedBackupFile; + private string? selectedBackupFileName; + private bool isRestoring; + + private int DatabaseSchemaVersion => CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion; protected override async Task OnInitializedAsync() { @@ -87,4 +141,56 @@ else ThemeState.SetThemeMode(settings.ThemeMode); statusMessage = $"Saved at {DateTime.Now:t}"; } + + private void OnRestoreFileSelected(InputFileChangeEventArgs args) + { + selectedBackupFile = args.File; + selectedBackupFileName = args.File.Name; + backupStatusMessage = null; + backupErrorMessage = null; + } + + private async Task RestoreAsync() + { + if (selectedBackupFile is null) + { + backupErrorMessage = "Select a JSON backup file first."; + backupStatusMessage = null; + return; + } + + var confirmed = await JS.InvokeAsync("confirm", "Restore this backup and overwrite the current database?\nThis cannot be undone."); + if (!confirmed) + { + return; + } + + isRestoring = true; + backupStatusMessage = null; + backupErrorMessage = null; + + try + { + await using var stream = selectedBackupFile.OpenReadStream(MaxBackupFileSize); + await DatabaseBackupService.ImportAsync(stream); + + settings = await AppSettingsService.GetAsync(); + ThemeState.SetThemeMode(settings.ThemeMode); + selectedBackupFile = null; + selectedBackupFileName = null; + backupStatusMessage = $"Backup restored at {DateTime.Now:t}."; + } + catch (DatabaseBackupException exception) + { + backupErrorMessage = exception.Message; + } + catch (IOException) + { + backupErrorMessage = "The selected backup file is too large or could not be read."; + } + finally + { + isRestoring = false; + } + } } diff --git a/Program.cs b/Program.cs index d67f0ef..312d5e6 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; @@ -70,10 +71,13 @@ builder.Services.Configure(builder.Configuration.GetSectio builder.Services.Configure(builder.Configuration.GetSection(SingleUserOptions.SectionName)); builder.Services.AddSingleton(); +builder.Services.AddScoped(); 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 +249,30 @@ 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(); + +app.MapGet("/api/database-backup/export", async ( + IDatabaseBackupService backupService, + CancellationToken cancellationToken) => +{ + var file = await backupService.ExportAsync(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/Services/Storage/CouchbaseLiteDatabaseProvider.cs b/Services/Storage/CouchbaseLiteDatabaseProvider.cs index 18126d5..4fd68f3 100644 --- a/Services/Storage/CouchbaseLiteDatabaseProvider.cs +++ b/Services/Storage/CouchbaseLiteDatabaseProvider.cs @@ -7,10 +7,15 @@ namespace WorkTracker.Services.Storage; public sealed class CouchbaseLiteDatabaseProvider : IDisposable { private const string AppSettingsCollectionName = "app_settings"; + private const string SystemCollectionName = "system"; private const string UsersCollectionName = "users"; private const string WorkDaysCollectionName = "workdays"; + private const string SchemaVersionDocumentId = "database_schema"; + + public const int CurrentDatabaseSchemaVersion = 1; private readonly Database database; + private readonly IReadOnlyDictionary backupCollections; public CouchbaseLiteDatabaseProvider(IOptions options, IHostEnvironment environment) { @@ -25,17 +30,39 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable Directory = databaseDirectory }); + System = database.GetCollection(SystemCollectionName) ?? database.CreateCollection(SystemCollectionName); AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName); Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName); WorkDays = database.GetCollection(WorkDaysCollectionName) ?? database.CreateCollection(WorkDaysCollectionName); + + backupCollections = new Dictionary(StringComparer.Ordinal) + { + [AppSettingsCollectionName] = AppSettings, + [UsersCollectionName] = Users, + [WorkDaysCollectionName] = WorkDays + }; + + DatabaseSchemaVersion = EnsureDatabaseSchemaVersion(); } + public Collection System { get; } + public Collection AppSettings { get; } public Collection Users { get; } public Collection WorkDays { get; } + public int DatabaseSchemaVersion { get; } + + public IReadOnlyDictionary BackupCollections => backupCollections; + + public void ExecuteInBatch(Action action) + { + ArgumentNullException.ThrowIfNull(action); + database.InBatch(action); + } + public void Dispose() { database.Close(); @@ -53,4 +80,21 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable ? configuredDirectory : Path.GetFullPath(Path.Combine(contentRootPath, configuredDirectory)); } + + private int EnsureDatabaseSchemaVersion() + { + var document = System.GetDocument(SchemaVersionDocumentId); + if (document is not null && document.Contains("schemaVersion")) + { + return document.GetInt("schemaVersion"); + } + + var now = DateTimeOffset.UtcNow; + var mutableDocument = new MutableDocument(SchemaVersionDocumentId); + mutableDocument.SetInt("schemaVersion", CurrentDatabaseSchemaVersion); + mutableDocument.SetString("createdAtUtc", now.ToString("O")); + mutableDocument.SetString("updatedAtUtc", now.ToString("O")); + System.Save(mutableDocument); + return CurrentDatabaseSchemaVersion; + } } \ No newline at end of file diff --git a/Services/Storage/DatabaseBackupModels.cs b/Services/Storage/DatabaseBackupModels.cs new file mode 100644 index 0000000..cbc0664 --- /dev/null +++ b/Services/Storage/DatabaseBackupModels.cs @@ -0,0 +1,38 @@ +using System.Text.Json; + +namespace WorkTracker.Services.Storage; + +public sealed class DatabaseBackupFile +{ + public required byte[] Content { get; init; } + + public required string ContentType { get; init; } + + public required string FileName { get; init; } +} + +public sealed class DatabaseBackupPayload +{ + public int BackupFormatVersion { get; init; } + + public int DatabaseSchemaVersion { get; init; } + + public DateTimeOffset ExportedAtUtc { get; init; } + + public Dictionary> Collections { get; init; } = []; +} + +public sealed class DatabaseBackupDocumentPayload +{ + public string Id { get; init; } = string.Empty; + + public JsonElement Data { get; init; } +} + +public sealed class DatabaseBackupException : Exception +{ + public DatabaseBackupException(string message) + : base(message) + { + } +} \ No newline at end of file diff --git a/Services/Storage/IDatabaseBackupService.cs b/Services/Storage/IDatabaseBackupService.cs new file mode 100644 index 0000000..eb6d256 --- /dev/null +++ b/Services/Storage/IDatabaseBackupService.cs @@ -0,0 +1,8 @@ +namespace WorkTracker.Services.Storage; + +public interface IDatabaseBackupService +{ + Task ExportAsync(CancellationToken cancellationToken = default); + + Task ImportAsync(Stream jsonStream, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Services/Storage/JsonDatabaseBackupService.cs b/Services/Storage/JsonDatabaseBackupService.cs new file mode 100644 index 0000000..1e1ea74 --- /dev/null +++ b/Services/Storage/JsonDatabaseBackupService.cs @@ -0,0 +1,204 @@ +using System.Text; +using System.Text.Json; +using Couchbase.Lite; +using Couchbase.Lite.Query; + +namespace WorkTracker.Services.Storage; + +public sealed class JsonDatabaseBackupService(CouchbaseLiteDatabaseProvider databaseProvider) : IDatabaseBackupService +{ + private const int CurrentBackupFormatVersion = 1; + + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + public Task ExportAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureSupportedDatabaseSchemaVersion(databaseProvider.DatabaseSchemaVersion); + + var payload = new DatabaseBackupPayload + { + BackupFormatVersion = CurrentBackupFormatVersion, + DatabaseSchemaVersion = databaseProvider.DatabaseSchemaVersion, + ExportedAtUtc = DateTimeOffset.UtcNow, + Collections = ExportCollections(cancellationToken) + }; + + var content = JsonSerializer.SerializeToUtf8Bytes(payload, JsonSerializerOptions); + var timestamp = payload.ExportedAtUtc.ToString("yyyyMMdd-HHmmss"); + + return Task.FromResult(new DatabaseBackupFile + { + Content = content, + ContentType = "application/json", + FileName = $"worktracker-backup-v{payload.DatabaseSchemaVersion}-{timestamp}.json" + }); + } + + public async Task ImportAsync(Stream jsonStream, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(jsonStream); + cancellationToken.ThrowIfCancellationRequested(); + + DatabaseBackupPayload payload; + + try + { + payload = await JsonSerializer.DeserializeAsync(jsonStream, JsonSerializerOptions, cancellationToken) + ?? throw new DatabaseBackupException("Backup file is empty or invalid."); + } + catch (JsonException exception) + { + throw new DatabaseBackupException($"Backup file is not valid JSON: {exception.Message}"); + } + + ValidatePayload(payload); + + databaseProvider.ExecuteInBatch(() => + { + foreach (var collection in databaseProvider.BackupCollections.Values) + { + ClearCollection(collection); + } + + foreach (var (collectionName, documents) in payload.Collections) + { + var collection = databaseProvider.BackupCollections[collectionName]; + + foreach (var document in documents) + { + var mutableDocument = new MutableDocument(document.Id); + mutableDocument.SetJSON(document.Data.GetRawText()); + collection.Save(mutableDocument); + } + } + }); + } + + private Dictionary> ExportCollections(CancellationToken cancellationToken) + { + var collections = new Dictionary>(StringComparer.Ordinal); + + foreach (var (collectionName, collection) in databaseProvider.BackupCollections) + { + cancellationToken.ThrowIfCancellationRequested(); + + collections[collectionName] = GetDocuments(collection, cancellationToken) + .Select(document => new DatabaseBackupDocumentPayload + { + Id = document.Id, + Data = ParseJson(document.ToJSON()) + }) + .ToList(); + } + + return collections; + } + + private void ValidatePayload(DatabaseBackupPayload payload) + { + if (payload.BackupFormatVersion <= 0) + { + throw new DatabaseBackupException("Backup file does not declare a supported format version."); + } + + EnsureSupportedBackupFormatVersion(payload.BackupFormatVersion); + EnsureSupportedDatabaseSchemaVersion(payload.DatabaseSchemaVersion); + + if (payload.DatabaseSchemaVersion != databaseProvider.DatabaseSchemaVersion) + { + throw new DatabaseBackupException($"Backup schema version {payload.DatabaseSchemaVersion} does not match database schema version {databaseProvider.DatabaseSchemaVersion}."); + } + + var expectedCollectionNames = databaseProvider.BackupCollections.Keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray(); + var actualCollectionNames = payload.Collections.Keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray(); + + if (!expectedCollectionNames.SequenceEqual(actualCollectionNames, StringComparer.Ordinal)) + { + throw new DatabaseBackupException("Backup file does not contain the expected collection set for this application version."); + } + + foreach (var (collectionName, documents) in payload.Collections) + { + foreach (var document in documents) + { + if (string.IsNullOrWhiteSpace(document.Id)) + { + throw new DatabaseBackupException($"Backup contains a document without an id in collection '{collectionName}'."); + } + + if (document.Data.ValueKind is not JsonValueKind.Object) + { + throw new DatabaseBackupException($"Backup document '{document.Id}' in collection '{collectionName}' must be a JSON object."); + } + } + } + } + + private static void EnsureSupportedBackupFormatVersion(int backupFormatVersion) + { + if (backupFormatVersion == CurrentBackupFormatVersion) + { + return; + } + + throw new DatabaseBackupException($"Backup format version {backupFormatVersion} is not supported by this build. Add a backup format migration before importing it."); + } + + private static void EnsureSupportedDatabaseSchemaVersion(int databaseSchemaVersion) + { + if (databaseSchemaVersion == CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion) + { + return; + } + + throw new DatabaseBackupException($"Database schema version {databaseSchemaVersion} is not supported by this build. Add a schema migration before using this backup."); + } + + private static List GetDocuments(Collection collection, CancellationToken cancellationToken) + { + var query = QueryBuilder + .Select(SelectResult.Expression(Meta.ID)) + .From(DataSource.Collection(collection)) + .OrderBy(Ordering.Expression(Meta.ID)); + + var documents = new List(); + foreach (var result in query.Execute()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var id = result.GetString(0); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + var document = collection.GetDocument(id); + if (document is not null) + { + documents.Add(document); + } + } + + return documents; + } + + private static void ClearCollection(Collection collection) + { + foreach (var document in GetDocuments(collection, CancellationToken.None)) + { + collection.Delete(document); + } + } + + private static JsonElement ParseJson(string json) + { + using var jsonDocument = JsonDocument.Parse(json); + return jsonDocument.RootElement.Clone(); + } +} \ 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/docs/date-range-backup-spec.md b/docs/date-range-backup-spec.md new file mode 100644 index 0000000..3b373ad --- /dev/null +++ b/docs/date-range-backup-spec.md @@ -0,0 +1,81 @@ +# Date-Range Backup Spec + +## Status + +- Draft only. +- Do not implement until full-backup import/export has been exercised in real usage. + +## Goal + +Allow users to export and import only a selected time range without weakening the safety guarantees introduced by full JSON backups. + +## Non-goals + +- No implementation in this change. +- No automatic migration across JSON backup format versions. +- No merging logic for conflicting edits yet. + +## Proposed backup modes + +- `full`: current behavior, exports every managed collection and can fully replace the database. +- `dateRange`: future behavior, exports only documents relevant to a user-selected inclusive date interval. + +## Proposed JSON shape changes + +Add a top-level `scope` object while keeping the existing version markers. + +```json +{ + "backupFormatVersion": 1, + "databaseSchemaVersion": 1, + "exportedAtUtc": "2026-04-24T12:00:00Z", + "scope": { + "mode": "full" + }, + "collections": { + "app_settings": [], + "users": [], + "workdays": [] + } +} +``` + +Future date-range backups would use: + +```json +"scope": { + "mode": "dateRange", + "from": "2026-04-01", + "to": "2026-04-30" +} +``` + +## Selection rules for `dateRange` + +- `workdays`: include documents whose logical day falls within `from` and `to` inclusive. +- `app_settings`: exclude by default. Settings are global, not time-scoped. +- `users`: exclude by default. Authentication data is global, not time-scoped. +- Future collections must declare whether they are `global`, `timeScoped`, or `derived` before they participate in range backups. + +## Import semantics for `dateRange` + +- Import must not reuse the current full-overwrite flow. +- Import must require a different confirmation message than full restore. +- Import must define conflict rules before implementation. +- Preferred first version: replace only the selected date range inside time-scoped collections. +- Global collections must remain untouched unless explicitly opted in by the user and supported by the backup format version. + +## Compatibility and versioning requirements + +- Keep `backupFormatVersion` and `databaseSchemaVersion` mandatory for every backup mode. +- If `scope.mode` is unknown, reject import. +- If `backupFormatVersion` is unsupported, reject import and surface that a migration/importer is required. +- If `databaseSchemaVersion` is unsupported, reject import and surface that a schema migration is required. +- When date-range import is implemented, version-specific import handlers should be introduced behind a dispatch layer such as `ImportV1Full`, `ImportV1DateRange`, and future `ImportV2*` handlers. + +## UI requirements for future work + +- Add date pickers for `from` and `to`. +- Validate `from <= to` before export/import is enabled. +- Show a summary of what will be affected before confirming import. +- Keep full-backup export/import available as the safer recovery path. \ No newline at end of file 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/JsonDatabaseBackupServiceTests.cs b/tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs new file mode 100644 index 0000000..4967412 --- /dev/null +++ b/tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs @@ -0,0 +1,167 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Couchbase.Lite; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using WorkTracker.Configuration; +using WorkTracker.Domain; +using WorkTracker.Services.Settings; +using WorkTracker.Services.Storage; +using Xunit; + +namespace WorkTracker.Tests; + +public sealed class JsonDatabaseBackupServiceTests +{ + [Fact] + public async Task ExportAndImport_RoundTripsManagedCollectionsAndVersions() + { + using var sourceDatabase = new TestDatabaseHandle(); + await SeedAsync(sourceDatabase.Provider); + + var backupService = new JsonDatabaseBackupService(sourceDatabase.Provider); + var backupFile = await backupService.ExportAsync(); + + using var backupJson = JsonDocument.Parse(backupFile.Content); + Assert.Equal(1, backupJson.RootElement.GetProperty("backupFormatVersion").GetInt32()); + Assert.Equal(CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion, backupJson.RootElement.GetProperty("databaseSchemaVersion").GetInt32()); + Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("app_settings", out _)); + Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("users", out _)); + Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("workdays", out _)); + + using var targetDatabase = new TestDatabaseHandle(); + var importService = new JsonDatabaseBackupService(targetDatabase.Provider); + + await using (var stream = new MemoryStream(backupFile.Content, writable: false)) + { + await importService.ImportAsync(stream); + } + + var appSettingsService = new CouchbaseLiteAppSettingsService(targetDatabase.Provider); + var importedSettings = await appSettingsService.GetAsync(); + + Assert.Equal(AppThemeMode.Dark, importedSettings.ThemeMode); + Assert.Equal("USD", importedSettings.Currency); + Assert.Equal("en-US", importedSettings.Locale); + Assert.Equal(CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion, targetDatabase.Provider.DatabaseSchemaVersion); + + var importedUser = targetDatabase.Provider.Users.GetDocument("USER1"); + Assert.NotNull(importedUser); + Assert.Equal("admin", importedUser!.GetString("username")); + + var importedWorkDay = targetDatabase.Provider.WorkDays.GetDocument("2026-04-24"); + Assert.NotNull(importedWorkDay); + Assert.Equal("2026-04-24", importedWorkDay!.GetString("date")); + } + + [Fact] + public async Task Import_WhenSchemaVersionDoesNotMatch_RejectsWithoutOverwritingDatabase() + { + using var sourceDatabase = new TestDatabaseHandle(); + await SeedAsync(sourceDatabase.Provider); + + var backupService = new JsonDatabaseBackupService(sourceDatabase.Provider); + var backupFile = await backupService.ExportAsync(); + + var modifiedBackup = JsonNode.Parse(backupFile.Content)!; + modifiedBackup["databaseSchemaVersion"] = CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion + 1; + var modifiedContent = Encoding.UTF8.GetBytes(modifiedBackup.ToJsonString(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + + using var targetDatabase = new TestDatabaseHandle(); + SeedExistingTargetData(targetDatabase.Provider); + var importService = new JsonDatabaseBackupService(targetDatabase.Provider); + + await using var stream = new MemoryStream(modifiedContent, writable: false); + var exception = await Assert.ThrowsAsync(() => importService.ImportAsync(stream)); + + Assert.Contains("not supported", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(targetDatabase.Provider.Users.GetDocument("KEEP")); + Assert.Null(targetDatabase.Provider.Users.GetDocument("USER1")); + } + + private static async Task SeedAsync(CouchbaseLiteDatabaseProvider provider) + { + var appSettingsService = new CouchbaseLiteAppSettingsService(provider); + await appSettingsService.SaveAsync(new AppSettingsDocument + { + ThemeMode = AppThemeMode.Dark, + Currency = "USD", + Locale = "en-US", + StandardWorkHoursPerDay = 7.5m, + HourlyGrossRate = 30m, + ProfitabilityCoefficient = 0.8m, + InpsRate = 0.2m, + SubstituteTaxRate = 0.1m + }); + + var userDocument = new MutableDocument("USER1"); + userDocument.SetString("username", "admin"); + userDocument.SetString("usernameNormalized", "ADMIN"); + userDocument.SetString("passwordHash", "hash"); + userDocument.SetBoolean("mustChangePassword", false); + provider.Users.Save(userDocument); + + var workDayDocument = new MutableDocument("2026-04-24"); + workDayDocument.SetString("date", "2026-04-24"); + workDayDocument.SetBoolean("isWeekend", false); + workDayDocument.SetBoolean("isItalianFestivity", false); + workDayDocument.SetArray("workUnits", new MutableArrayObject()); + workDayDocument.SetArray("calendarEvents", new MutableArrayObject()); + workDayDocument.SetString("createdAtUtc", "2026-04-24T08:00:00.0000000+00:00"); + workDayDocument.SetString("updatedAtUtc", "2026-04-24T08:00:00.0000000+00:00"); + provider.WorkDays.Save(workDayDocument); + } + + private static void SeedExistingTargetData(CouchbaseLiteDatabaseProvider provider) + { + var existingUser = new MutableDocument("KEEP"); + existingUser.SetString("username", "keep-me"); + existingUser.SetString("usernameNormalized", "KEEP-ME"); + existingUser.SetString("passwordHash", "keep-hash"); + existingUser.SetBoolean("mustChangePassword", false); + provider.Users.Save(existingUser); + } + + private sealed class TestDatabaseHandle : IDisposable + { + private readonly string directoryPath; + + public TestDatabaseHandle() + { + directoryPath = Path.Combine(Path.GetTempPath(), "WorkTracker.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directoryPath); + + Provider = new CouchbaseLiteDatabaseProvider( + Options.Create(new CouchbaseLiteOptions + { + Directory = directoryPath, + DatabaseName = $"worktracker-tests-{Guid.NewGuid():N}" + }), + new TestHostEnvironment()); + } + + public CouchbaseLiteDatabaseProvider Provider { get; } + + public void Dispose() + { + Provider.Dispose(); + + if (Directory.Exists(directoryPath)) + { + Directory.Delete(directoryPath, recursive: true); + } + } + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Development"; + + public string ApplicationName { get; set; } = "WorkTracker.Tests"; + + public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory(); + + public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; + } +} \ No newline at end of file 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() + }; + } +}