Fix puncture prose boundary splitting

This commit is contained in:
2026-03-14 13:11:27 +01:00
parent f2a45656de
commit 839241ea62
3 changed files with 82 additions and 10 deletions

Binary file not shown.

View File

@@ -136,6 +136,25 @@ public sealed class StandardCriticalTableParserIntegrationTests
Assert.Equal(1, bleedEffects.Single().PerRound); Assert.Equal(1, bleedEffects.Single().PerRound);
} }
[Fact]
public async Task Puncture_91_95_keeps_a_and_b_prose_in_separate_columns()
{
var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "puncture", StringComparison.Ordinal));
var parseResult = await LoadParseResultAsync(entry);
var row91A = parseResult.Table.Results.Single(item =>
item.GroupKey is null &&
string.Equals(item.RollBandLabel, "91-95", StringComparison.Ordinal) &&
string.Equals(item.ColumnKey, "A", StringComparison.Ordinal));
var row91B = parseResult.Table.Results.Single(item =>
item.GroupKey is null &&
string.Equals(item.RollBandLabel, "91-95", StringComparison.Ordinal) &&
string.Equals(item.ColumnKey, "B", StringComparison.Ordinal));
Assert.Equal("Strike to foe's ear. Foe hears at -50.", row91A.DescriptionText);
Assert.DoesNotContain("Strike to foe's hip.", row91A.DescriptionText, StringComparison.Ordinal);
Assert.Contains("Strike to foe's hip.", row91B.DescriptionText, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task Slash_boundary_repair_keeps_56_60_a_prose_first() public async Task Slash_boundary_repair_keeps_56_60_a_prose_first()
{ {
@@ -460,6 +479,30 @@ public sealed class StandardCriticalTableParserIntegrationTests
Assert.Contains(result.Branches.SelectMany(item => item.Effects), item => item.EffectCode == CriticalEffectCodes.BleedPerRound); Assert.Contains(result.Branches.SelectMany(item => item.Effects), item => item.EffectCode == CriticalEffectCodes.BleedPerRound);
} }
[Fact]
public async Task Loader_persists_puncture_b_91_95_description_text()
{
var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "puncture", StringComparison.Ordinal));
var parseResult = await LoadParseResultAsync(entry);
var databasePath = CreateTemporaryDatabaseCopy();
var loader = new CriticalImportLoader(databasePath);
await loader.LoadAsync(parseResult.Table);
await using var dbContext = CreateDbContext(databasePath);
var result = await dbContext.CriticalResults
.Include(item => item.CriticalTable)
.Include(item => item.CriticalColumn)
.Include(item => item.CriticalRollBand)
.SingleAsync(item =>
item.CriticalTable.Slug == "puncture" &&
item.CriticalColumn.ColumnKey == "B" &&
item.CriticalRollBand.Label == "91-95");
Assert.Contains("Strike to foe's hip.", result.DescriptionText, StringComparison.Ordinal);
Assert.StartsWith("Strike to foe's hip.", result.RawCellText, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task Lookup_service_returns_effects_for_results_and_branches() public async Task Lookup_service_returns_effects_for_results_and_branches()
{ {

View File

@@ -15,6 +15,7 @@ internal static class CriticalTableParserSupport
internal const int TopGroupingTolerance = 2; internal const int TopGroupingTolerance = 2;
private static readonly Regex MultiFragmentSplitRegex = new(@"\S(?:.*?\S)?(?=(?:\s{2,}|$))", RegexOptions.Compiled); private static readonly Regex MultiFragmentSplitRegex = new(@"\S(?:.*?\S)?(?=(?:\s{2,}|$))", RegexOptions.Compiled);
private static readonly Regex SentenceFragmentSplitRegex = new(@"\S.*?(?:[.!?](?:['"")\]]*)|$)", RegexOptions.Compiled);
private static readonly Regex NumericAffixLineRegex = new(@"^\d+(?:H|∑|∏|π|∫|\s*[-])", RegexOptions.Compiled); private static readonly Regex NumericAffixLineRegex = new(@"^\d+(?:H|∑|∏|π|∫|\s*[-])", RegexOptions.Compiled);
private static readonly Regex StandaloneModifierAffixLineRegex = new(@"^(?:\d+)?\((?:\+|-|)\d+\)$", RegexOptions.Compiled); private static readonly Regex StandaloneModifierAffixLineRegex = new(@"^(?:\d+)?\((?:\+|-|)\d+\)$", RegexOptions.Compiled);
@@ -373,7 +374,7 @@ internal static class CriticalTableParserSupport
supportsPowerPointModifier: footerText.Contains("powerpoint modification", StringComparison.OrdinalIgnoreCase)); supportsPowerPointModifier: footerText.Contains("powerpoint modification", StringComparison.OrdinalIgnoreCase));
} }
internal static List<XmlTextFragment> SplitBoundaryCrossingAffixFragments( internal static List<XmlTextFragment> SplitBoundaryCrossingFragments(
IReadOnlyList<XmlTextFragment> bodyFragments, IReadOnlyList<XmlTextFragment> bodyFragments,
IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlyList<(string Key, double CenterX)> columnCenters,
IReadOnlySet<string> affixLegendSymbols) IReadOnlySet<string> affixLegendSymbols)
@@ -382,7 +383,7 @@ internal static class CriticalTableParserSupport
foreach (var fragment in bodyFragments) foreach (var fragment in bodyFragments)
{ {
splitFragments.AddRange(SplitBoundaryCrossingAffixFragment(fragment, columnCenters, affixLegendSymbols)); splitFragments.AddRange(SplitBoundaryCrossingFragment(fragment, columnCenters, affixLegendSymbols));
} }
return splitFragments; return splitFragments;
@@ -467,7 +468,7 @@ internal static class CriticalTableParserSupport
!excludedFragments.Contains(item)) !excludedFragments.Contains(item))
.ToList(); .ToList();
return SplitBoundaryCrossingAffixFragments(bodyFragments, columnCenters, affixLegendSymbols); return SplitBoundaryCrossingFragments(bodyFragments, columnCenters, affixLegendSymbols);
} }
internal static void RepairLeadingAffixLeakage(List<ColumnarCellEntry> cellEntries, IReadOnlySet<string> affixLegendSymbols) internal static void RepairLeadingAffixLeakage(List<ColumnarCellEntry> cellEntries, IReadOnlySet<string> affixLegendSymbols)
@@ -612,17 +613,17 @@ internal static class CriticalTableParserSupport
return true; return true;
} }
private static IReadOnlyList<XmlTextFragment> SplitBoundaryCrossingAffixFragment( private static IReadOnlyList<XmlTextFragment> SplitBoundaryCrossingFragment(
XmlTextFragment fragment, XmlTextFragment fragment,
IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlyList<(string Key, double CenterX)> columnCenters,
IReadOnlySet<string> affixLegendSymbols) IReadOnlySet<string> affixLegendSymbols)
{ {
if (!LooksLikeBoundaryCrossingAffixFragment(fragment, columnCenters, affixLegendSymbols)) if (!TryGetBoundaryCrossingPattern(fragment, columnCenters, affixLegendSymbols, out var splitPattern))
{ {
return [fragment]; return [fragment];
} }
var matches = MultiFragmentSplitRegex.Matches(fragment.Text); var matches = splitPattern.Matches(fragment.Text);
if (matches.Count < 2) if (matches.Count < 2)
{ {
return [fragment]; return [fragment];
@@ -667,17 +668,40 @@ internal static class CriticalTableParserSupport
: [fragment]; : [fragment];
} }
private static bool LooksLikeBoundaryCrossingAffixFragment( private static bool TryGetBoundaryCrossingPattern(
XmlTextFragment fragment, XmlTextFragment fragment,
IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlyList<(string Key, double CenterX)> columnCenters,
IReadOnlySet<string> affixLegendSymbols) IReadOnlySet<string> affixLegendSymbols,
out Regex splitPattern)
{ {
if (!IsAffixLikeLine(fragment.Text, affixLegendSymbols) || splitPattern = null!;
!fragment.Text.Contains(" ", StringComparison.Ordinal))
if (!CrossesColumnBoundary(fragment, columnCenters))
{ {
return false; return false;
} }
if (IsAffixLikeLine(fragment.Text, affixLegendSymbols) &&
fragment.Text.Contains(" ", StringComparison.Ordinal))
{
splitPattern = MultiFragmentSplitRegex;
return true;
}
if (!IsAffixLikeLine(fragment.Text, affixLegendSymbols) &&
CountSentenceLikeSegments(fragment.Text) >= 2)
{
splitPattern = SentenceFragmentSplitRegex;
return true;
}
return false;
}
private static bool CrossesColumnBoundary(
XmlTextFragment fragment,
IReadOnlyList<(string Key, double CenterX)> columnCenters)
{
var fragmentRight = fragment.Left + fragment.Width; var fragmentRight = fragment.Left + fragment.Width;
for (var index = 0; index < columnCenters.Count - 1; index++) for (var index = 0; index < columnCenters.Count - 1; index++)
@@ -692,6 +716,11 @@ internal static class CriticalTableParserSupport
return false; return false;
} }
private static int CountSentenceLikeSegments(string text) =>
SentenceFragmentSplitRegex.Matches(text)
.Select(match => CollapseWhitespace(match.Value))
.Count(value => !string.IsNullOrWhiteSpace(value));
private static void AddLegendMatch( private static void AddLegendMatch(
IDictionary<string, string> symbolEffects, IDictionary<string, string> symbolEffects,
string value, string value,