diff --git a/src/RolemasterDb.App/Features/LookupContracts.cs b/src/RolemasterDb.App/Features/LookupContracts.cs index 2fd203e..74eeb43 100644 --- a/src/RolemasterDb.App/Features/LookupContracts.cs +++ b/src/RolemasterDb.App/Features/LookupContracts.cs @@ -34,6 +34,9 @@ public sealed record CriticalTableReference( string Family, string SourceDocument, string? Notes, + int CuratedResultCount, + int TotalResultCount, + int CurationPercentage, IReadOnlyList Columns, IReadOnlyList Groups, IReadOnlyList RollBands); diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs index 88d4524..1f31de3 100644 --- a/src/RolemasterDb.App/Features/LookupService.cs +++ b/src/RolemasterDb.App/Features/LookupService.cs @@ -43,6 +43,20 @@ public sealed class LookupService( .OrderBy(item => item.DisplayName) .ToListAsync(cancellationToken); + var criticalResultCounts = await dbContext.CriticalResults + .AsNoTracking() + .GroupBy(item => item.CriticalTableId) + .Select(group => new + { + CriticalTableId = group.Key, + TotalCount = group.Count(), + CuratedCount = group.Count(item => item.IsCurated) + }) + .ToDictionaryAsync( + item => item.CriticalTableId, + item => (item.CuratedCount, item.TotalCount), + cancellationToken); + return new LookupReferenceData( attackTables, armorTypes, @@ -52,6 +66,9 @@ public sealed class LookupService( item.Family, item.SourceDocument, item.Notes, + GetCuratedCount(item.Id), + GetTotalCount(item.Id), + GetCurationPercentage(item.Id), item.Columns .OrderBy(column => column.SortOrder) .Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder)) @@ -65,6 +82,22 @@ public sealed class LookupService( .Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder)) .ToList())) .ToList()); + + int GetCuratedCount(int tableId) => + criticalResultCounts.TryGetValue(tableId, out var counts) ? counts.CuratedCount : 0; + + int GetTotalCount(int tableId) => + criticalResultCounts.TryGetValue(tableId, out var counts) ? counts.TotalCount : 0; + + int GetCurationPercentage(int tableId) + { + if (!criticalResultCounts.TryGetValue(tableId, out var counts) || counts.TotalCount == 0) + { + return 0; + } + + return (int)Math.Round((double)counts.CuratedCount * 100 / counts.TotalCount, MidpointRounding.AwayFromZero); + } } public async Task LookupAttackAsync(AttackLookupRequest request, CancellationToken cancellationToken = default) @@ -383,12 +416,15 @@ public sealed class LookupService( return null; } + var currentState = CreateCurrentEditorState(result); + var hasEdits = HasCriticalCellEdits(currentState, request); + result.RawCellText = request.RawCellText.Trim(); result.DescriptionText = request.DescriptionText.Trim(); result.RawAffixText = NormalizeOptionalText(request.RawAffixText); result.ParseStatus = request.ParseStatus.Trim(); result.ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson(); - result.IsCurated = request.IsCurated; + result.IsCurated = hasEdits ? false : request.IsCurated; ReplaceBaseEffects(dbContext, result, request.Effects); ReplaceBranches(dbContext, result, request.Branches); @@ -396,7 +432,11 @@ public sealed class LookupService( await dbContext.SaveChangesAsync(cancellationToken); var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.QuickParseInput, cancellationToken); - return CreateCellEditorResponse(result, request, generatedContent.ValidationErrors, CreateComparisonState(generatedContent)); + var persistedState = request with + { + IsCurated = result.IsCurated + }; + return CreateCellEditorResponse(result, persistedState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent)); } private static IReadOnlyList BuildLegend(IReadOnlyList cells) @@ -1023,6 +1063,96 @@ public sealed class LookupService( private static string? NormalizeOptionalText(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static bool HasCriticalCellEdits(CriticalCellUpdateRequest currentState, CriticalCellUpdateRequest request) + { + if (!string.Equals(currentState.RawCellText.Trim(), request.RawCellText.Trim(), StringComparison.Ordinal) || + !string.Equals(currentState.QuickParseInput.Trim(), request.QuickParseInput.Trim(), StringComparison.Ordinal) || + !string.Equals(currentState.DescriptionText.Trim(), request.DescriptionText.Trim(), StringComparison.Ordinal) || + !string.Equals(NormalizeOptionalText(currentState.RawAffixText), NormalizeOptionalText(request.RawAffixText), StringComparison.Ordinal) || + !string.Equals(currentState.ParseStatus.Trim(), request.ParseStatus.Trim(), StringComparison.Ordinal) || + currentState.IsDescriptionOverridden != request.IsDescriptionOverridden || + currentState.IsRawAffixTextOverridden != request.IsRawAffixTextOverridden || + currentState.AreEffectsOverridden != request.AreEffectsOverridden || + currentState.AreBranchesOverridden != request.AreBranchesOverridden) + { + return true; + } + + return !EffectListsEqual(currentState.Effects, request.Effects) || + !BranchListsEqual(currentState.Branches, request.Branches); + } + + private static bool EffectListsEqual( + IReadOnlyList left, + IReadOnlyList right) + { + if (left.Count != right.Count) + { + return false; + } + + for (var index = 0; index < left.Count; index++) + { + if (!EffectsEqual(left[index], right[index])) + { + return false; + } + } + + return true; + } + + private static bool BranchListsEqual( + IReadOnlyList left, + IReadOnlyList right) + { + if (left.Count != right.Count) + { + return false; + } + + for (var index = 0; index < left.Count; index++) + { + if (!BranchesEqual(left[index], right[index])) + { + return false; + } + } + + return true; + } + + private static bool EffectsEqual(CriticalEffectEditorItem left, CriticalEffectEditorItem right) => + string.Equals(left.EffectCode.Trim(), right.EffectCode.Trim(), StringComparison.Ordinal) && + string.Equals(NormalizeOptionalText(left.Target), NormalizeOptionalText(right.Target), StringComparison.Ordinal) && + left.ValueInteger == right.ValueInteger && + left.ValueDecimal == right.ValueDecimal && + string.Equals(NormalizeOptionalText(left.ValueExpression), NormalizeOptionalText(right.ValueExpression), StringComparison.Ordinal) && + left.DurationRounds == right.DurationRounds && + left.PerRound == right.PerRound && + left.Modifier == right.Modifier && + string.Equals(NormalizeOptionalText(left.BodyPart), NormalizeOptionalText(right.BodyPart), StringComparison.Ordinal) && + left.IsPermanent == right.IsPermanent && + string.Equals(left.SourceType.Trim(), right.SourceType.Trim(), StringComparison.Ordinal) && + string.Equals(NormalizeOptionalText(left.SourceText), NormalizeOptionalText(right.SourceText), StringComparison.Ordinal) && + string.Equals(NormalizeOptionalText(left.OriginKey), NormalizeOptionalText(right.OriginKey), StringComparison.Ordinal) && + left.IsOverridden == right.IsOverridden; + + private static bool BranchesEqual(CriticalBranchEditorItem left, CriticalBranchEditorItem right) => + string.Equals(left.BranchKind.Trim(), right.BranchKind.Trim(), StringComparison.Ordinal) && + string.Equals(NormalizeOptionalText(left.ConditionKey), NormalizeOptionalText(right.ConditionKey), StringComparison.Ordinal) && + string.Equals(left.ConditionText.Trim(), right.ConditionText.Trim(), StringComparison.Ordinal) && + string.Equals(left.ConditionJson.Trim(), right.ConditionJson.Trim(), StringComparison.Ordinal) && + string.Equals(left.RawText.Trim(), right.RawText.Trim(), StringComparison.Ordinal) && + string.Equals(left.DescriptionText.Trim(), right.DescriptionText.Trim(), StringComparison.Ordinal) && + string.Equals(NormalizeOptionalText(left.RawAffixText), NormalizeOptionalText(right.RawAffixText), StringComparison.Ordinal) && + string.Equals(left.ParsedJson.Trim(), right.ParsedJson.Trim(), StringComparison.Ordinal) && + left.SortOrder == right.SortOrder && + string.Equals(NormalizeOptionalText(left.OriginKey), NormalizeOptionalText(right.OriginKey), StringComparison.Ordinal) && + left.IsOverridden == right.IsOverridden && + left.AreEffectsOverridden == right.AreEffectsOverridden && + EffectListsEqual(left.Effects, right.Effects); + private static string NormalizeSlug(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant(); diff --git a/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs b/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs index df9cc57..6583fa4 100644 --- a/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs @@ -155,6 +155,9 @@ public sealed class LookupRollingTests "standard", $"{label}.pdf", null, + 0, + 0, + 0, [], [], rollBands); diff --git a/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs index c6080ca..8f54d23 100644 --- a/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs @@ -56,6 +56,56 @@ public sealed class LookupServiceCurationIntegrationTests Assert.Equal(initialResponse.SourceImageUrl, reopenedResponse.SourceImageUrl); } + [Fact] + public async Task Lookup_service_clears_curated_state_when_any_content_is_edited() + { + var databasePath = CreateEmptyDatabasePath(); + var repositoryRoot = CreateTemporaryRepositoryRoot(); + var locator = new CriticalImportArtifactLocator(new TestHostEnvironment(Path.Combine(repositoryRoot, "src", "RolemasterDb.App"))); + + await SeedCriticalResultAsync(databasePath, "slash/cells/source-cell.png", 2); + WriteSourceImage(repositoryRoot, "slash/cells/source-cell.png"); + + var lookupService = new LookupService(CreateDbContextFactory(databasePath), locator); + var resultId = await GetResultIdAsync(databasePath); + + var initialResponse = await lookupService.GetCriticalCellEditorAsync("slash", resultId); + Assert.NotNull(initialResponse); + + var markCuratedRequest = new CriticalCellUpdateRequest( + initialResponse!.RawCellText, + initialResponse.QuickParseInput, + initialResponse.DescriptionText, + initialResponse.RawAffixText, + initialResponse.ParseStatus, + initialResponse.ParsedJson, + true, + initialResponse.IsDescriptionOverridden, + initialResponse.IsRawAffixTextOverridden, + initialResponse.AreEffectsOverridden, + initialResponse.AreBranchesOverridden, + initialResponse.Effects, + initialResponse.Branches); + + var curatedResponse = await lookupService.UpdateCriticalCellAsync("slash", resultId, markCuratedRequest); + Assert.NotNull(curatedResponse); + Assert.True(curatedResponse!.IsCurated); + + var editedRequest = markCuratedRequest with + { + DescriptionText = "Edited description after curation." + }; + + var editedResponse = await lookupService.UpdateCriticalCellAsync("slash", resultId, editedRequest); + Assert.NotNull(editedResponse); + Assert.False(editedResponse!.IsCurated); + + var reopenedResponse = await lookupService.GetCriticalCellEditorAsync("slash", resultId); + Assert.NotNull(reopenedResponse); + Assert.False(reopenedResponse!.IsCurated); + Assert.Equal("Edited description after curation.", reopenedResponse.DescriptionText); + } + [Fact] public async Task Lookup_service_resolves_source_image_paths_only_when_artifacts_exist() {