diff --git a/Components/Pages/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor index 8ebbc97..6debd76 100644 --- a/Components/Pages/MonthlySummary.razor +++ b/Components/Pages/MonthlySummary.razor @@ -16,7 +16,6 @@

@currentMonth.ToString("MMMM yyyy")

- Download Excel Yearly Summary @@ -278,11 +277,6 @@ 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 64fca6e..38d2689 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -1,13 +1,8 @@ @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 @@ -70,60 +65,11 @@ 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() { @@ -141,56 +87,4 @@ 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 312d5e6..d67f0ef 100644 --- a/Program.cs +++ b/Program.cs @@ -13,7 +13,6 @@ 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; @@ -71,13 +70,10 @@ 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(); @@ -249,30 +245,6 @@ 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 deleted file mode 100644 index 39b6643..0000000 --- a/Services/Exports/IMonthlyTimesheetExcelExportService.cs +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 59646b4..0000000 --- a/Services/Exports/IMonthlyTimesheetExcelExporter.cs +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index f4b7c9a..0000000 --- a/Services/Exports/MonthlyTimesheetExcelExportService.cs +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index b6b7036..0000000 --- a/Services/Exports/MonthlyTimesheetExcelExporter.cs +++ /dev/null @@ -1,230 +0,0 @@ -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 4fd68f3..18126d5 100644 --- a/Services/Storage/CouchbaseLiteDatabaseProvider.cs +++ b/Services/Storage/CouchbaseLiteDatabaseProvider.cs @@ -7,15 +7,10 @@ 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) { @@ -30,39 +25,17 @@ 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(); @@ -80,21 +53,4 @@ 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 deleted file mode 100644 index cbc0664..0000000 --- a/Services/Storage/DatabaseBackupModels.cs +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index eb6d256..0000000 --- a/Services/Storage/IDatabaseBackupService.cs +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 1e1ea74..0000000 --- a/Services/Storage/JsonDatabaseBackupService.cs +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index 0d9df9d..0000000 Binary files a/Templates/monthly-timesheet-template.xlsx and /dev/null differ diff --git a/WorkTracker.csproj b/WorkTracker.csproj index c24a782..c764034 100644 --- a/WorkTracker.csproj +++ b/WorkTracker.csproj @@ -8,26 +8,15 @@ - - - - - - - PreserveNewest - - PreserveNewest - PreserveNewest - diff --git a/WorkTracker.sln b/WorkTracker.sln index b6f3355..4c0c3b9 100644 --- a/WorkTracker.sln +++ b/WorkTracker.sln @@ -1,55 +1,23 @@ - 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 deleted file mode 100644 index 3b373ad..0000000 --- a/docs/date-range-backup-spec.md +++ /dev/null @@ -1,81 +0,0 @@ -# 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 deleted file mode 100644 index 3658645..0000000 Binary files a/tests/WorkTracker.Tests/Expected/Timesheet Aprile 2026-filled.xlsx and /dev/null differ diff --git a/tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs b/tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs deleted file mode 100644 index 4967412..0000000 --- a/tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -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 deleted file mode 100644 index bc55c78..0000000 --- a/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index f8039eb..0000000 --- a/tests/WorkTracker.Tests/WorkTracker.Tests.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - 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 deleted file mode 100644 index c7a2772..0000000 --- a/tests/WorkTracker.Tests/WorkbookAssert.cs +++ /dev/null @@ -1,90 +0,0 @@ -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() - }; - } -}