diff --git a/src/RolemasterDb.ImportTool.Tests/CriticalImportMergeIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/CriticalImportMergeIntegrationTests.cs new file mode 100644 index 0000000..96490a4 --- /dev/null +++ b/src/RolemasterDb.ImportTool.Tests/CriticalImportMergeIntegrationTests.cs @@ -0,0 +1,292 @@ +using Microsoft.EntityFrameworkCore; + +using RolemasterDb.App.Data; +using RolemasterDb.App.Domain; +using RolemasterDb.ImportTool.Parsing; + +namespace RolemasterDb.ImportTool.Tests; + +public sealed class CriticalImportMergeIntegrationTests +{ + private static readonly PdfXmlExtractor Extractor = new(); + private static readonly StandardCriticalTableParser StandardParser = new(); + + [Fact] + public async Task Reimport_updates_uncurated_results_and_refreshes_source_metadata() + { + var (parseResult, _) = await LoadPreparedSlashParseResultAsync(); + var databasePath = CreateEmptyDatabasePath(); + var loader = new CriticalImportLoader(databasePath); + + await loader.LoadAsync(parseResult.Table); + + await using (var dbContext = CreateDbContext(databasePath)) + { + var result = await LoadResultAsync(dbContext, "36-45", "B"); + result.IsCurated = false; + result.RawCellText = "Temporary raw text"; + result.DescriptionText = "Temporary description"; + result.RawAffixText = "+99H"; + result.ParseStatus = "manually_curated"; + result.SourcePageNumber = null; + result.SourceImagePath = null; + result.SourceImageCropJson = null; + + dbContext.CriticalEffects.RemoveRange(result.Effects); + foreach (var branch in result.Branches) + { + dbContext.CriticalEffects.RemoveRange(branch.Effects); + } + + dbContext.CriticalBranches.RemoveRange(result.Branches); + result.Effects.Clear(); + result.Branches.Clear(); + await dbContext.SaveChangesAsync(); + } + + await loader.LoadAsync(parseResult.Table); + + await using (var dbContext = CreateDbContext(databasePath)) + { + var result = await LoadResultAsync(dbContext, "36-45", "B"); + + Assert.NotEqual("Temporary raw text", result.RawCellText); + Assert.NotEqual("Temporary description", result.DescriptionText); + Assert.NotEqual("+99H", result.RawAffixText); + Assert.NotEqual("manually_curated", result.ParseStatus); + Assert.NotNull(result.SourcePageNumber); + Assert.False(string.IsNullOrWhiteSpace(result.SourceImagePath)); + Assert.False(string.IsNullOrWhiteSpace(result.SourceImageCropJson)); + Assert.NotEmpty(result.Branches); + } + } + + [Fact] + public async Task Reimport_preserves_curated_results_and_child_rows() + { + var (parseResult, _) = await LoadPreparedSlashParseResultAsync(); + var databasePath = CreateEmptyDatabasePath(); + var loader = new CriticalImportLoader(databasePath); + + await loader.LoadAsync(parseResult.Table); + + await using (var dbContext = CreateDbContext(databasePath)) + { + var result = await LoadResultAsync(dbContext, "36-45", "B"); + + result.IsCurated = true; + result.RawCellText = "Curated raw text"; + result.DescriptionText = "Curated result prose."; + result.RawAffixText = "+12H"; + result.ParseStatus = "manually_curated"; + result.ParsedJson = "{\"reviewed\":true}"; + + dbContext.CriticalEffects.RemoveRange(result.Effects); + foreach (var branch in result.Branches) + { + dbContext.CriticalEffects.RemoveRange(branch.Effects); + } + + dbContext.CriticalBranches.RemoveRange(result.Branches); + result.Effects.Clear(); + result.Branches.Clear(); + + result.Effects.Add(new CriticalEffect + { + EffectCode = CriticalEffectCodes.DirectHits, + ValueInteger = 12, + IsPermanent = false, + SourceType = "manual", + SourceText = "+12H" + }); + + result.Branches.Add(new CriticalBranch + { + BranchKind = "conditional", + ConditionKey = "with_shield", + ConditionText = "with shield", + ConditionJson = "{}", + RawText = "with shield: glancing blow.", + DescriptionText = "Glancing blow.", + ParsedJson = "{}", + SortOrder = 1, + Effects = + [ + new CriticalEffect + { + EffectCode = CriticalEffectCodes.MustParryRounds, + DurationRounds = 2, + IsPermanent = false, + SourceType = "manual", + SourceText = "2mp" + } + ] + }); + + await dbContext.SaveChangesAsync(); + } + + await loader.LoadAsync(parseResult.Table); + + await using (var dbContext = CreateDbContext(databasePath)) + { + var result = await LoadResultAsync(dbContext, "36-45", "B"); + + Assert.True(result.IsCurated); + Assert.Equal("Curated raw text", result.RawCellText); + Assert.Equal("Curated result prose.", result.DescriptionText); + Assert.Equal("+12H", result.RawAffixText); + Assert.Equal("manually_curated", result.ParseStatus); + Assert.Equal("{\"reviewed\":true}", result.ParsedJson); + Assert.Single(result.Effects); + Assert.Equal(12, result.Effects[0].ValueInteger); + Assert.Single(result.Branches); + Assert.Equal("with_shield", result.Branches[0].ConditionKey); + Assert.Single(result.Branches[0].Effects); + Assert.Equal(2, result.Branches[0].Effects[0].DurationRounds); + Assert.False(string.IsNullOrWhiteSpace(result.SourceImagePath)); + Assert.False(string.IsNullOrWhiteSpace(result.SourceImageCropJson)); + } + } + + [Fact] + public async Task Reimport_deletes_only_unmatched_uncurated_results() + { + var (parseResult, _) = await LoadPreparedSlashParseResultAsync(); + var databasePath = CreateEmptyDatabasePath(); + var loader = new CriticalImportLoader(databasePath); + + await loader.LoadAsync(parseResult.Table); + + await using (var dbContext = CreateDbContext(databasePath)) + { + var curatedMissing = await LoadResultAsync(dbContext, "01-05", "A"); + curatedMissing.IsCurated = true; + curatedMissing.DescriptionText = "Keep me."; + + var uncuratedMissing = await LoadResultAsync(dbContext, "01-05", "B"); + uncuratedMissing.IsCurated = false; + + await dbContext.SaveChangesAsync(); + } + + var trimmedTable = CreateTrimmedTable( + parseResult.Table, + ("01-05", "A"), + ("01-05", "B")); + + await loader.LoadAsync(trimmedTable); + + await using (var dbContext = CreateDbContext(databasePath)) + { + var results = await dbContext.CriticalResults + .Include(item => item.CriticalTable) + .Include(item => item.CriticalColumn) + .Include(item => item.CriticalRollBand) + .Where(item => item.CriticalTable.Slug == "slash" && item.CriticalRollBand.Label == "01-05") + .ToListAsync(); + + Assert.Contains(results, item => item.CriticalColumn.ColumnKey == "A" && item.IsCurated && item.DescriptionText == "Keep me."); + Assert.DoesNotContain(results, item => item.CriticalColumn.ColumnKey == "B"); + } + } + + private static ParsedCriticalTable CreateTrimmedTable( + ParsedCriticalTable table, + params (string RollBandLabel, string ColumnKey)[] excludedResults) + { + var excludedKeys = excludedResults + .Select(item => $"{item.RollBandLabel}|{item.ColumnKey}") + .ToHashSet(StringComparer.Ordinal); + + return new ParsedCriticalTable( + table.Slug, + table.DisplayName, + table.Family, + table.SourceDocument, + table.Notes, + table.Groups, + table.Columns, + table.RollBands, + table.Results + .Where(item => !excludedKeys.Contains($"{item.RollBandLabel}|{item.ColumnKey}")) + .ToList()); + } + + private static async Task<(CriticalTableParseResult ParseResult, ImportArtifactPaths ArtifactPaths)> LoadPreparedSlashParseResultAsync() + { + var entry = LoadManifest().Tables.Single(item => item.Slug == "slash"); + var xmlPath = Path.Combine(GetArtifactCacheRoot(), $"{entry.Slug}.xml"); + + if (!File.Exists(xmlPath)) + { + await Extractor.ExtractAsync(Path.Combine(GetRepositoryRoot(), entry.PdfPath), xmlPath); + } + + var parseResult = StandardParser.Parse(entry, await File.ReadAllTextAsync(xmlPath)); + var artifactRoot = Path.Combine(GetArtifactCacheRoot(), Guid.NewGuid().ToString("N")); + var artifactPaths = ImportArtifactPaths.Create(artifactRoot, entry.Slug); + var generator = new CriticalSourceImageArtifactGenerator(new PdfXmlExtractor()); + + await generator.GenerateAsync(Path.Combine(GetRepositoryRoot(), entry.PdfPath), artifactPaths, parseResult); + return (parseResult, artifactPaths); + } + + private static async Task LoadResultAsync(RolemasterDbContext dbContext, string rollBandLabel, string columnKey) => + await dbContext.CriticalResults + .Include(item => item.CriticalTable) + .Include(item => item.CriticalColumn) + .Include(item => item.CriticalRollBand) + .Include(item => item.Effects) + .Include(item => item.Branches) + .ThenInclude(branch => branch.Effects) + .SingleAsync(item => + item.CriticalTable.Slug == "slash" && + item.CriticalRollBand.Label == rollBandLabel && + item.CriticalColumn.ColumnKey == columnKey); + + private static ParsedCriticalResult FindResult(CriticalTableParseResult parseResult, string rollBandLabel, string columnKey) => + parseResult.Table.Results.Single(item => + item.GroupKey is null && + item.RollBandLabel == rollBandLabel && + item.ColumnKey == columnKey); + + private static CriticalImportManifest LoadManifest() => + new CriticalImportManifestLoader().Load(Path.Combine(GetRepositoryRoot(), "sources", "critical-import-manifest.json")); + + private static RolemasterDbContext CreateDbContext(string databasePath) + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath}") + .Options; + + return new RolemasterDbContext(options); + } + + private static string CreateEmptyDatabasePath() => + Path.Combine(GetArtifactCacheRoot(), $"critical-import-{Guid.NewGuid():N}.db"); + + private static string GetArtifactCacheRoot() + { + var cacheRoot = Path.Combine(Path.GetTempPath(), "RolemasterDb.ImportTool.MergeTests"); + Directory.CreateDirectory(cacheRoot); + return cacheRoot; + } + + private static string GetRepositoryRoot() + { + var probe = new DirectoryInfo(AppContext.BaseDirectory); + + while (probe is not null) + { + if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx"))) + { + return probe.FullName; + } + + probe = probe.Parent; + } + + throw new InvalidOperationException("Could not find the repository root for integration tests."); + } +} diff --git a/src/RolemasterDb.ImportTool/CriticalImportLoader.cs b/src/RolemasterDb.ImportTool/CriticalImportLoader.cs index 3c05c98..7ca83c5 100644 --- a/src/RolemasterDb.ImportTool/CriticalImportLoader.cs +++ b/src/RolemasterDb.ImportTool/CriticalImportLoader.cs @@ -42,85 +42,86 @@ public sealed class CriticalImportLoader(string databasePath) await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext, cancellationToken); await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - await DeleteTableAsync(dbContext, table.Slug, cancellationToken); + var entity = await dbContext.CriticalTables + .AsSplitQuery() + .Include(item => item.Groups) + .Include(item => item.Columns) + .Include(item => item.RollBands) + .Include(item => item.Results) + .ThenInclude(result => result.CriticalGroup) + .Include(item => item.Results) + .ThenInclude(result => result.CriticalColumn) + .Include(item => item.Results) + .ThenInclude(result => result.CriticalRollBand) + .Include(item => item.Results) + .ThenInclude(result => result.Effects) + .Include(item => item.Results) + .ThenInclude(result => result.Branches) + .ThenInclude(branch => branch.Effects) + .SingleOrDefaultAsync(item => item.Slug == table.Slug, cancellationToken); - var entity = new CriticalTable + if (entity is null) { - Slug = table.Slug, - DisplayName = table.DisplayName, - Family = table.Family, - SourceDocument = table.SourceDocument, - Notes = table.Notes - }; - - entity.Groups = table.Groups - .Select(item => new CriticalGroup + entity = new CriticalTable { - GroupKey = item.GroupKey, - Label = item.Label, - SortOrder = item.SortOrder - }) - .ToList(); + Slug = table.Slug + }; + dbContext.CriticalTables.Add(entity); + } - entity.Columns = table.Columns - .Select(item => new CriticalColumn + entity.DisplayName = table.DisplayName; + entity.Family = table.Family; + entity.SourceDocument = table.SourceDocument; + entity.Notes = table.Notes; + + var groupsByKey = SynchronizeGroups(entity, table); + var columnsByKey = SynchronizeColumns(entity, table); + var rollBandsByLabel = SynchronizeRollBands(entity, table); + var existingResultsByKey = entity.Results.ToDictionary( + item => CreateResultKey(item.CriticalGroup?.GroupKey, item.CriticalColumn.ColumnKey, item.CriticalRollBand.Label), + StringComparer.Ordinal); + var importedKeys = new HashSet(StringComparer.Ordinal); + + foreach (var item in table.Results) + { + var resultKey = CreateResultKey(item.GroupKey, item.ColumnKey, item.RollBandLabel); + importedKeys.Add(resultKey); + + if (!existingResultsByKey.TryGetValue(resultKey, out var existingResult)) { - ColumnKey = item.ColumnKey, - Label = item.Label, - Role = item.Role, - SortOrder = item.SortOrder - }) - .ToList(); + var newResult = CreateResultEntity(item, entity, groupsByKey, columnsByKey, rollBandsByLabel); + entity.Results.Add(newResult); + continue; + } - entity.RollBands = table.RollBands - .Select(item => new CriticalRollBand + existingResult.CriticalGroup = item.GroupKey is null ? null : groupsByKey[item.GroupKey]; + existingResult.CriticalColumn = columnsByKey[item.ColumnKey]; + existingResult.CriticalRollBand = rollBandsByLabel[item.RollBandLabel]; + ApplyImporterProvenance(existingResult, item); + + if (existingResult.IsCurated) { - Label = item.Label, - MinRoll = item.MinRoll, - MaxRoll = item.MaxRoll, - SortOrder = item.SortOrder - }) - .ToList(); + continue; + } - var groupsByKey = entity.Groups.ToDictionary(item => item.GroupKey, StringComparer.OrdinalIgnoreCase); - var columnsByKey = entity.Columns.ToDictionary(item => item.ColumnKey, StringComparer.OrdinalIgnoreCase); - var rollBandsByLabel = entity.RollBands.ToDictionary(item => item.Label, StringComparer.OrdinalIgnoreCase); + ApplyImportedContent(existingResult, item); + ReplaceResultChildren(dbContext, existingResult, item); + } - entity.Results = table.Results - .Select(item => new CriticalResult + foreach (var unmatchedResult in entity.Results + .Where(item => !importedKeys.Contains(CreateResultKey(item.CriticalGroup?.GroupKey, item.CriticalColumn.ColumnKey, item.CriticalRollBand.Label))) + .ToList()) + { + if (unmatchedResult.IsCurated) { - CriticalGroup = item.GroupKey is null ? null : groupsByKey[item.GroupKey], - CriticalColumn = columnsByKey[item.ColumnKey], - CriticalRollBand = rollBandsByLabel[item.RollBandLabel], - RawCellText = item.RawCellText, - DescriptionText = item.DescriptionText, - RawAffixText = item.RawAffixText, - ParsedJson = SerializeParsedEffects(item.Effects), - ParseStatus = ResolveParseStatus(item.Effects, item.Branches), - Effects = item.Effects - .Select(CreateEffectEntity) - .ToList(), - Branches = item.Branches - .Select(branch => new CriticalBranch - { - BranchKind = branch.BranchKind, - ConditionKey = branch.ConditionKey, - ConditionText = branch.ConditionText, - ConditionJson = "{}", - RawText = branch.RawText, - DescriptionText = branch.DescriptionText, - RawAffixText = branch.RawAffixText, - ParsedJson = SerializeParsedEffects(branch.Effects), - SortOrder = branch.SortOrder, - Effects = branch.Effects - .Select(CreateEffectEntity) - .ToList() - }) - .ToList() - }) - .ToList(); + continue; + } - dbContext.CriticalTables.Add(entity); + RemoveResultGraph(dbContext, unmatchedResult); + entity.Results.Remove(unmatchedResult); + } + + RemoveUnusedAxes(dbContext, entity, table); await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); @@ -136,52 +137,207 @@ public sealed class CriticalImportLoader(string databasePath) return new RolemasterDbContext(options); } - private static async Task DeleteTableAsync( - RolemasterDbContext dbContext, - string slug, - CancellationToken cancellationToken) + private static Dictionary SynchronizeGroups(CriticalTable entity, ParsedCriticalTable table) { - var tableId = await dbContext.CriticalTables - .Where(item => item.Slug == slug) - .Select(item => (int?)item.Id) - .SingleOrDefaultAsync(cancellationToken); + var groupsByKey = entity.Groups.ToDictionary(item => item.GroupKey, StringComparer.OrdinalIgnoreCase); - if (tableId is null) + foreach (var parsedGroup in table.Groups) { - return; + if (!groupsByKey.TryGetValue(parsedGroup.GroupKey, out var group)) + { + group = new CriticalGroup + { + CriticalTable = entity, + GroupKey = parsedGroup.GroupKey + }; + entity.Groups.Add(group); + groupsByKey[parsedGroup.GroupKey] = group; + } + + group.Label = parsedGroup.Label; + group.SortOrder = parsedGroup.SortOrder; } - await dbContext.CriticalEffects - .Where(item => item.CriticalBranch != null && item.CriticalBranch.CriticalResult.CriticalTableId == tableId.Value) - .ExecuteDeleteAsync(cancellationToken); + return groupsByKey; + } - await dbContext.CriticalEffects - .Where(item => item.CriticalResult != null && item.CriticalResult.CriticalTableId == tableId.Value) - .ExecuteDeleteAsync(cancellationToken); + private static Dictionary SynchronizeColumns(CriticalTable entity, ParsedCriticalTable table) + { + var columnsByKey = entity.Columns.ToDictionary(item => item.ColumnKey, StringComparer.OrdinalIgnoreCase); - await dbContext.CriticalBranches - .Where(item => item.CriticalResult.CriticalTableId == tableId.Value) - .ExecuteDeleteAsync(cancellationToken); + foreach (var parsedColumn in table.Columns) + { + if (!columnsByKey.TryGetValue(parsedColumn.ColumnKey, out var column)) + { + column = new CriticalColumn + { + CriticalTable = entity, + ColumnKey = parsedColumn.ColumnKey + }; + entity.Columns.Add(column); + columnsByKey[parsedColumn.ColumnKey] = column; + } - await dbContext.CriticalResults - .Where(item => item.CriticalTableId == tableId.Value) - .ExecuteDeleteAsync(cancellationToken); + column.Label = parsedColumn.Label; + column.Role = parsedColumn.Role; + column.SortOrder = parsedColumn.SortOrder; + } - await dbContext.CriticalGroups - .Where(item => item.CriticalTableId == tableId.Value) - .ExecuteDeleteAsync(cancellationToken); + return columnsByKey; + } - await dbContext.CriticalColumns - .Where(item => item.CriticalTableId == tableId.Value) - .ExecuteDeleteAsync(cancellationToken); + private static Dictionary SynchronizeRollBands(CriticalTable entity, ParsedCriticalTable table) + { + var rollBandsByLabel = entity.RollBands.ToDictionary(item => item.Label, StringComparer.OrdinalIgnoreCase); - await dbContext.CriticalRollBands - .Where(item => item.CriticalTableId == tableId.Value) - .ExecuteDeleteAsync(cancellationToken); + foreach (var parsedRollBand in table.RollBands) + { + if (!rollBandsByLabel.TryGetValue(parsedRollBand.Label, out var rollBand)) + { + rollBand = new CriticalRollBand + { + CriticalTable = entity, + Label = parsedRollBand.Label + }; + entity.RollBands.Add(rollBand); + rollBandsByLabel[parsedRollBand.Label] = rollBand; + } - await dbContext.CriticalTables - .Where(item => item.Id == tableId.Value) - .ExecuteDeleteAsync(cancellationToken); + rollBand.MinRoll = parsedRollBand.MinRoll; + rollBand.MaxRoll = parsedRollBand.MaxRoll; + rollBand.SortOrder = parsedRollBand.SortOrder; + } + + return rollBandsByLabel; + } + + private static CriticalResult CreateResultEntity( + ParsedCriticalResult item, + CriticalTable entity, + IReadOnlyDictionary groupsByKey, + IReadOnlyDictionary columnsByKey, + IReadOnlyDictionary rollBandsByLabel) + { + var result = new CriticalResult + { + CriticalTable = entity, + CriticalGroup = item.GroupKey is null ? null : groupsByKey[item.GroupKey], + CriticalColumn = columnsByKey[item.ColumnKey], + CriticalRollBand = rollBandsByLabel[item.RollBandLabel], + IsCurated = false + }; + + ApplyImportedContent(result, item); + ApplyImporterProvenance(result, item); + result.Effects = item.Effects + .Select(CreateEffectEntity) + .ToList(); + result.Branches = item.Branches + .Select(CreateBranchEntity) + .ToList(); + return result; + } + + private static void ApplyImportedContent(CriticalResult result, ParsedCriticalResult item) + { + result.RawCellText = item.RawCellText; + result.DescriptionText = item.DescriptionText; + result.RawAffixText = item.RawAffixText; + result.ParsedJson = SerializeParsedEffects(item.Effects); + result.ParseStatus = ResolveParseStatus(item.Effects, item.Branches); + } + + private static void ApplyImporterProvenance(CriticalResult result, ParsedCriticalResult item) + { + result.SourcePageNumber = item.SourceBounds.PageNumber; + result.SourceImagePath = NormalizeOptionalText(item.SourceImagePath); + result.SourceImageCropJson = item.SourceImageCrop is null + ? null + : JsonSerializer.Serialize(item.SourceImageCrop, JsonOptions); + } + + private static void ReplaceResultChildren(RolemasterDbContext dbContext, CriticalResult result, ParsedCriticalResult item) + { + foreach (var branch in result.Branches) + { + dbContext.CriticalEffects.RemoveRange(branch.Effects); + } + + dbContext.CriticalEffects.RemoveRange(result.Effects); + dbContext.CriticalBranches.RemoveRange(result.Branches); + result.Effects.Clear(); + result.Branches.Clear(); + + foreach (var effect in item.Effects) + { + result.Effects.Add(CreateEffectEntity(effect)); + } + + foreach (var branch in item.Branches) + { + result.Branches.Add(CreateBranchEntity(branch)); + } + } + + private static void RemoveResultGraph(RolemasterDbContext dbContext, CriticalResult result) + { + foreach (var branch in result.Branches) + { + dbContext.CriticalEffects.RemoveRange(branch.Effects); + } + + dbContext.CriticalEffects.RemoveRange(result.Effects); + dbContext.CriticalBranches.RemoveRange(result.Branches); + dbContext.CriticalResults.Remove(result); + } + + private static void RemoveUnusedAxes(RolemasterDbContext dbContext, CriticalTable entity, ParsedCriticalTable table) + { + var activeGroupKeys = entity.Results + .Where(item => item.CriticalGroup is not null) + .Select(item => item.CriticalGroup!.GroupKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var importedGroupKeys = table.Groups + .Select(item => item.GroupKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var group in entity.Groups + .Where(item => !activeGroupKeys.Contains(item.GroupKey) && !importedGroupKeys.Contains(item.GroupKey)) + .ToList()) + { + dbContext.CriticalGroups.Remove(group); + entity.Groups.Remove(group); + } + + var activeColumnKeys = entity.Results + .Select(item => item.CriticalColumn.ColumnKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var importedColumnKeys = table.Columns + .Select(item => item.ColumnKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var column in entity.Columns + .Where(item => !activeColumnKeys.Contains(item.ColumnKey) && !importedColumnKeys.Contains(item.ColumnKey)) + .ToList()) + { + dbContext.CriticalColumns.Remove(column); + entity.Columns.Remove(column); + } + + var activeRollBandLabels = entity.Results + .Select(item => item.CriticalRollBand.Label) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var importedRollBandLabels = table.RollBands + .Select(item => item.Label) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var rollBand in entity.RollBands + .Where(item => !activeRollBandLabels.Contains(item.Label) && !importedRollBandLabels.Contains(item.Label)) + .ToList()) + { + dbContext.CriticalRollBands.Remove(rollBand); + entity.RollBands.Remove(rollBand); + } } private static CriticalEffect CreateEffectEntity(ParsedCriticalEffect effect) => @@ -200,6 +356,23 @@ public sealed class CriticalImportLoader(string databasePath) SourceText = effect.SourceText }; + private static CriticalBranch CreateBranchEntity(ParsedCriticalBranch branch) => + new() + { + BranchKind = branch.BranchKind, + ConditionKey = branch.ConditionKey, + ConditionText = branch.ConditionText, + ConditionJson = "{}", + RawText = branch.RawText, + DescriptionText = branch.DescriptionText, + RawAffixText = branch.RawAffixText, + ParsedJson = SerializeParsedEffects(branch.Effects), + SortOrder = branch.SortOrder, + Effects = branch.Effects + .Select(CreateEffectEntity) + .ToList() + }; + private static string SerializeParsedEffects(IReadOnlyList effects) => effects.Count == 0 ? "{}" @@ -211,4 +384,13 @@ public sealed class CriticalImportLoader(string databasePath) effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0) ? "partial" : "raw"; + + private static string? NormalizeOptionalText(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string CreateResultKey(string? groupKey, string columnKey, string rollBandLabel) => + $"{NormalizeKey(groupKey)}|{NormalizeKey(columnKey)}|{NormalizeKey(rollBandLabel)}"; + + private static string NormalizeKey(string? value) => + string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); }