Track critical curation summaries and edit resets

This commit is contained in:
2026-03-18 00:45:20 +01:00
parent 8cbcf66695
commit 45873cd60c
4 changed files with 188 additions and 2 deletions

View File

@@ -34,6 +34,9 @@ public sealed record CriticalTableReference(
string Family,
string SourceDocument,
string? Notes,
int CuratedResultCount,
int TotalResultCount,
int CurationPercentage,
IReadOnlyList<CriticalColumnReference> Columns,
IReadOnlyList<CriticalGroupReference> Groups,
IReadOnlyList<CriticalRollBandReference> RollBands);

View File

@@ -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<AttackLookupResponse?> 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<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> 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<CriticalEffectEditorItem> left,
IReadOnlyList<CriticalEffectEditorItem> 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<CriticalBranchEditorItem> left,
IReadOnlyList<CriticalBranchEditorItem> 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();

View File

@@ -155,6 +155,9 @@ public sealed class LookupRollingTests
"standard",
$"{label}.pdf",
null,
0,
0,
0,
[],
[],
rollBands);

View File

@@ -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()
{