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"); } } [Fact] public async Task Reimport_images_only_refreshes_provenance_without_touching_curated_content() { 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 description"; result.RawAffixText = "+12H"; result.ParseStatus = "manually_curated"; result.SourcePageNumber = null; result.SourceImagePath = null; result.SourceImageCropJson = null; await dbContext.SaveChangesAsync(); } await loader.RefreshImageArtifactsAsync(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 description", result.DescriptionText); Assert.Equal("+12H", result.RawAffixText); Assert.Equal("manually_curated", result.ParseStatus); Assert.NotNull(result.SourcePageNumber); Assert.False(string.IsNullOrWhiteSpace(result.SourceImagePath)); Assert.False(string.IsNullOrWhiteSpace(result.SourceImageCropJson)); } } 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."); } }