Merge critical imports without overwriting curation

This commit is contained in:
2026-03-17 22:28:34 +01:00
parent 2936d7146f
commit 57fe9d217d
2 changed files with 576 additions and 102 deletions

View File

@@ -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<CriticalResult> 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<RolemasterDbContext>()
.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.");
}
}