Surface parser token review explicitly

This commit is contained in:
2026-03-15 15:33:19 +01:00
parent 47b72419ad
commit 74a9436c92
14 changed files with 355 additions and 41 deletions

View File

@@ -719,6 +719,17 @@ Acceptance criteria:
### Phase 7: Parser trustworthiness and token review ### Phase 7: Parser trustworthiness and token review
Status:
- implemented in the web app on March 15, 2026
Implemented model:
- quick-parse generation now records explicit token-review items for unknown or partially parsed affix tokens instead of relying only on generic note strings
- advanced review calls out unresolved tokens directly alongside generated comparison so curators can see what needs manual attention
- shared affix parsing no longer consumes unsupported legacy matches silently, preventing recognized content from disappearing without review
- regression coverage now includes unknown-token surfacing and keeps Arcane Aether B16 stable through load and re-parse flows
Scope: Scope:
- remove silent-loss behavior from generation - remove silent-loss behavior from generation

View File

@@ -74,7 +74,7 @@
"effects": [], "effects": [],
"branches": [] "branches": []
}</pre> }</pre>
<p class="panel-copy">Use this to retrieve the full editable result graph for one critical-table cell, including nested branches and normalized effects.</p> <p class="panel-copy">Use this to retrieve the full editable result graph for one critical-table cell, including nested branches, normalized effects, and review notes for unresolved quick-parse tokens.</p>
</section> </section>
<section class="panel"> <section class="panel">
@@ -95,7 +95,7 @@
"branches": [] "branches": []
} }
}</pre> }</pre>
<p class="panel-copy">Re-runs the shared single-cell parser, merges the generated result with the current override state, and returns the refreshed editor payload without saving changes.</p> <p class="panel-copy">Re-runs the shared single-cell parser, merges the generated result with the current override state, and returns the refreshed editor payload without saving changes. Unknown or partially parsed tokens are surfaced explicitly in the returned review data.</p>
</section> </section>
<section class="panel"> <section class="panel">

View File

@@ -255,6 +255,25 @@
} }
</div> </div>
</div> </div>
@if (Model.GeneratedState.TokenReviewItems.Count > 0)
{
<div class="critical-editor-card nested">
<div class="critical-editor-card-header">
<div>
<strong>Token Review</strong>
<p class="muted critical-editor-inline-copy">These tokens were unknown or only partially understood during generation and need manual review.</p>
</div>
</div>
<div class="critical-editor-validation-list">
@foreach (var issue in Model.GeneratedState.TokenReviewItems)
{
<p class="critical-editor-validation-item">@issue.ReviewText</p>
}
</div>
</div>
}
} }
</div> </div>
</details> </details>
@@ -621,6 +640,11 @@
items.Add("Current card matches the fresh generation"); items.Add("Current card matches the fresh generation");
} }
if (model.GeneratedState.TokenReviewItems.Count > 0)
{
items.Add($"{model.GeneratedState.TokenReviewItems.Count} token review item{(model.GeneratedState.TokenReviewItems.Count == 1 ? string.Empty : "s")}");
}
if (model.GeneratedState.ValidationMessages.Count > 0) if (model.GeneratedState.ValidationMessages.Count > 0)
{ {
items.Add($"{model.GeneratedState.ValidationMessages.Count} parser note{(model.GeneratedState.ValidationMessages.Count == 1 ? string.Empty : "s")}"); items.Add($"{model.GeneratedState.ValidationMessages.Count} parser note{(model.GeneratedState.ValidationMessages.Count == 1 ? string.Empty : "s")}");

View File

@@ -6,4 +6,5 @@ public sealed record CriticalCellComparisonState(
string DescriptionText, string DescriptionText,
IReadOnlyList<CriticalEffectLookupResponse> Effects, IReadOnlyList<CriticalEffectLookupResponse> Effects,
IReadOnlyList<CriticalBranchLookupResponse> Branches, IReadOnlyList<CriticalBranchLookupResponse> Branches,
IReadOnlyList<string> ValidationMessages); IReadOnlyList<string> ValidationMessages,
IReadOnlyList<CriticalTokenReviewItem> TokenReviewItems);

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace RolemasterDb.App.Features;
public sealed record CriticalTokenReviewItem(
string Scope,
string? ConditionText,
string Token,
string ReviewText);

View File

@@ -535,7 +535,17 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
.OrderBy(branch => branch.SortOrder) .OrderBy(branch => branch.SortOrder)
.Select(CreateBranchLookupResponse) .Select(CreateBranchLookupResponse)
.ToList(), .ToList(),
content.ValidationErrors.ToList()); content.ValidationErrors.ToList(),
content.TokenReviewIssues
.Select(CreateTokenReviewItem)
.ToList());
private static CriticalTokenReviewItem CreateTokenReviewItem(SharedParsing.CriticalTokenReviewIssue issue) =>
new(
issue.Scope,
issue.ConditionText,
issue.Token,
issue.ReviewText);
private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect, string originKey) => private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect, string originKey) =>
new( new(

View File

@@ -35,39 +35,40 @@ public static class AffixEffectParser
return effects; return effects;
} }
public static bool TryParseAffixToken( internal static AffixTokenParseResult ParseAffixToken(
string token, string token,
AffixLegend affixLegend, AffixLegend affixLegend)
out IReadOnlyList<ParsedCriticalEffect> effects,
out string normalizedToken)
{ {
normalizedToken = CriticalCellParserSupport.CollapseWhitespace(token); var normalizedToken = CriticalCellParserSupport.CollapseWhitespace(token);
if (string.IsNullOrWhiteSpace(normalizedToken) || normalizedToken is "-" or "" or "—")
{
return new AffixTokenParseResult([], normalizedToken, []);
}
if (TryParseQuickToken(normalizedToken, out var quickEffects, out var canonicalToken)) if (TryParseQuickToken(normalizedToken, out var quickEffects, out var canonicalToken))
{ {
normalizedToken = canonicalToken; return new AffixTokenParseResult(quickEffects, canonicalToken, []);
effects = quickEffects;
return true;
} }
var legacyEffects = Parse(normalizedToken, affixLegend); return ParseLegacyToken(normalizedToken, affixLegend);
if (legacyEffects.Count > 0)
{
effects = legacyEffects;
return true;
}
effects = [];
return false;
} }
private static void ParseLine(string line, AffixLegend affixLegend, List<ParsedCriticalEffect> effects) private static void ParseLine(string line, AffixLegend affixLegend, List<ParsedCriticalEffect> effects)
{
ParseLine(line, affixLegend, effects, []);
}
private static void ParseLine(
string line,
AffixLegend affixLegend,
List<ParsedCriticalEffect> effects,
List<(int Start, int End)> consumedRanges)
{ {
if (string.IsNullOrWhiteSpace(line) || line is "-" or "" or "—") if (string.IsNullOrWhiteSpace(line) || line is "-" or "" or "—")
{ {
return; return;
} }
var consumedRanges = new List<(int Start, int End)>();
var matchedEffects = new List<(int Index, ParsedCriticalEffect Effect)>(); var matchedEffects = new List<(int Index, ParsedCriticalEffect Effect)>();
AddMatches( AddMatches(
@@ -180,6 +181,7 @@ public static class AffixEffectParser
continue; continue;
} }
var effectCountBeforeMatch = matchedEffects.Count;
var magnitude = match.Groups["count"].Success var magnitude = match.Groups["count"].Success
? int.Parse(match.Groups["count"].Value) ? int.Parse(match.Groups["count"].Value)
: 1; : 1;
@@ -195,6 +197,11 @@ public static class AffixEffectParser
matchedEffects.Add((match.Index, CreateSymbolEffect(effectCode, magnitude, matchedText))); matchedEffects.Add((match.Index, CreateSymbolEffect(effectCode, magnitude, matchedText)));
} }
if (matchedEffects.Count > effectCountBeforeMatch)
{
consumedRanges.Add((match.Index, match.Index + match.Length));
}
} }
if (matchedEffects.Count > 0) if (matchedEffects.Count > 0)
@@ -459,16 +466,84 @@ public static class AffixEffectParser
continue; continue;
} }
consumedRanges.Add((match.Index, match.Index + match.Length));
var effect = createEffect(match); var effect = createEffect(match);
if (effect is not null) if (effect is not null)
{ {
consumedRanges.Add((match.Index, match.Index + match.Length));
matchedEffects.Add((match.Index, effect)); matchedEffects.Add((match.Index, effect));
} }
} }
} }
private static AffixTokenParseResult ParseLegacyToken(string normalizedToken, AffixLegend affixLegend)
{
var effects = new List<ParsedCriticalEffect>();
var consumedRanges = new List<(int Start, int End)>();
ParseLine(normalizedToken, affixLegend, effects, consumedRanges);
var unresolvedFragments = ExtractUnresolvedFragments(normalizedToken, consumedRanges);
var canonicalToken = effects
.Select(effect => effect.SourceText)
.Where(sourceText => !string.IsNullOrWhiteSpace(sourceText))
.Distinct(StringComparer.Ordinal)
.ToList();
return new AffixTokenParseResult(
effects,
canonicalToken.Count == 0 ? normalizedToken : string.Join(", ", canonicalToken),
unresolvedFragments);
}
private static IReadOnlyList<string> ExtractUnresolvedFragments(
string normalizedToken,
IReadOnlyList<(int Start, int End)> consumedRanges)
{
if (string.IsNullOrWhiteSpace(normalizedToken))
{
return [];
}
if (consumedRanges.Count == 0)
{
return [normalizedToken];
}
var unresolved = new List<string>();
var orderedRanges = consumedRanges
.OrderBy(range => range.Start)
.ToList();
var currentIndex = 0;
foreach (var range in orderedRanges)
{
if (currentIndex < range.Start)
{
AddUnresolvedFragment(normalizedToken[currentIndex..range.Start], unresolved);
}
currentIndex = Math.Max(currentIndex, range.End);
}
if (currentIndex < normalizedToken.Length)
{
AddUnresolvedFragment(normalizedToken[currentIndex..], unresolved);
}
return unresolved;
}
private static void AddUnresolvedFragment(string candidate, List<string> unresolved)
{
var normalizedFragment = candidate
.Trim()
.Trim(',', ';', '/', '\\');
if (!string.IsNullOrWhiteSpace(normalizedFragment))
{
unresolved.Add(normalizedFragment);
}
}
private static bool OverlapsConsumedRange(Match match, IReadOnlyList<(int Start, int End)> consumedRanges) => private static bool OverlapsConsumedRange(Match match, IReadOnlyList<(int Start, int End)> consumedRanges) =>
consumedRanges.Any(range => match.Index < range.End && range.Start < match.Index + match.Length); consumedRanges.Any(range => match.Index < range.End && range.Start < match.Index + match.Length);

View File

@@ -0,0 +1,6 @@
namespace RolemasterDb.CriticalParsing;
internal sealed record AffixTokenParseResult(
IReadOnlyList<ParsedCriticalEffect> Effects,
string NormalizedToken,
IReadOnlyList<string> UnresolvedFragments);

View File

@@ -7,7 +7,8 @@ public sealed class CriticalCellParseContent(
string? rawAffixText, string? rawAffixText,
IReadOnlyList<ParsedCriticalEffect> effects, IReadOnlyList<ParsedCriticalEffect> effects,
IReadOnlyList<ParsedCriticalBranch> branches, IReadOnlyList<ParsedCriticalBranch> branches,
IReadOnlyList<string> validationErrors) IReadOnlyList<string> validationErrors,
IReadOnlyList<CriticalTokenReviewIssue> tokenReviewIssues)
{ {
public IReadOnlyList<string> BaseLines { get; } = baseLines; public IReadOnlyList<string> BaseLines { get; } = baseLines;
public string RawCellText { get; } = rawCellText; public string RawCellText { get; } = rawCellText;
@@ -16,4 +17,5 @@ public sealed class CriticalCellParseContent(
public IReadOnlyList<ParsedCriticalEffect> Effects { get; } = effects; public IReadOnlyList<ParsedCriticalEffect> Effects { get; } = effects;
public IReadOnlyList<ParsedCriticalBranch> Branches { get; } = branches; public IReadOnlyList<ParsedCriticalBranch> Branches { get; } = branches;
public IReadOnlyList<string> ValidationErrors { get; } = validationErrors; public IReadOnlyList<string> ValidationErrors { get; } = validationErrors;
public IReadOnlyList<CriticalTokenReviewIssue> TokenReviewIssues { get; } = tokenReviewIssues;
} }

View File

@@ -37,7 +37,7 @@ public static class CriticalCellTextParser
var (rawText, descriptionText, rawAffixText) = BuildTextSections(baseLines, affixLegendSymbols); var (rawText, descriptionText, rawAffixText) = BuildTextSections(baseLines, affixLegendSymbols);
var effects = AffixEffectParser.Parse(rawAffixText, affixLegend); var effects = AffixEffectParser.Parse(rawAffixText, affixLegend);
return new CriticalCellParseContent(baseLines, rawText, descriptionText, rawAffixText, effects, branches, validationErrors); return new CriticalCellParseContent(baseLines, rawText, descriptionText, rawAffixText, effects, branches, validationErrors, []);
} }
private static ParsedCriticalBranch ParseBranch( private static ParsedCriticalBranch ParseBranch(

View File

@@ -12,10 +12,11 @@ public static class CriticalQuickNotationParser
if (lines.Count == 0) if (lines.Count == 0)
{ {
return new CriticalCellParseContent([], string.Empty, string.Empty, null, [], [], []); return new CriticalCellParseContent([], string.Empty, string.Empty, null, [], [], [], []);
} }
var validationErrors = new List<string>(); var validationErrors = new List<string>();
var tokenReviewIssues = new List<CriticalTokenReviewIssue>();
var descriptionText = lines[0]; var descriptionText = lines[0];
var rawAffixLines = new List<string>(); var rawAffixLines = new List<string>();
var effects = new List<ParsedCriticalEffect>(); var effects = new List<ParsedCriticalEffect>();
@@ -25,11 +26,11 @@ public static class CriticalQuickNotationParser
{ {
if (line.Contains(':', StringComparison.Ordinal)) if (line.Contains(':', StringComparison.Ordinal))
{ {
branches.Add(ParseBranch(line, branches.Count + 1, affixLegend, validationErrors)); branches.Add(ParseBranch(line, branches.Count + 1, affixLegend, validationErrors, tokenReviewIssues));
continue; continue;
} }
var normalizedAffixLine = ParseBaseAffixLine(line, affixLegend, validationErrors, effects); var normalizedAffixLine = ParseBaseAffixLine(line, affixLegend, validationErrors, tokenReviewIssues, effects);
if (!string.IsNullOrWhiteSpace(normalizedAffixLine)) if (!string.IsNullOrWhiteSpace(normalizedAffixLine))
{ {
rawAffixLines.Add(normalizedAffixLine); rawAffixLines.Add(normalizedAffixLine);
@@ -43,13 +44,15 @@ public static class CriticalQuickNotationParser
rawAffixLines.Count == 0 ? null : string.Join(Environment.NewLine, rawAffixLines), rawAffixLines.Count == 0 ? null : string.Join(Environment.NewLine, rawAffixLines),
effects, effects,
branches, branches,
validationErrors); validationErrors,
tokenReviewIssues);
} }
private static string? ParseBaseAffixLine( private static string? ParseBaseAffixLine(
string line, string line,
AffixLegend affixLegend, AffixLegend affixLegend,
List<string> validationErrors, List<string> validationErrors,
List<CriticalTokenReviewIssue> tokenReviewIssues,
List<ParsedCriticalEffect> effects) List<ParsedCriticalEffect> effects)
{ {
var tokens = SplitPayload(line); var tokens = SplitPayload(line);
@@ -57,14 +60,20 @@ public static class CriticalQuickNotationParser
foreach (var token in tokens) foreach (var token in tokens)
{ {
if (!AffixEffectParser.TryParseAffixToken(token, affixLegend, out var parsedEffects, out var normalizedToken)) var parseResult = AffixEffectParser.ParseAffixToken(token, affixLegend);
if (parseResult.Effects.Count > 0)
{ {
validationErrors.Add($"Base content has unrecognized affix token '{token}'."); normalizedTokens.Add(parseResult.NormalizedToken);
continue; effects.AddRange(parseResult.Effects);
} }
normalizedTokens.Add(normalizedToken); AddTokenReviewIssues(
effects.AddRange(parsedEffects); tokenReviewIssues,
validationErrors,
scope: "base",
conditionText: null,
originalToken: token,
parseResult);
} }
return normalizedTokens.Count == 0 ? null : string.Join(", ", normalizedTokens); return normalizedTokens.Count == 0 ? null : string.Join(", ", normalizedTokens);
@@ -74,7 +83,8 @@ public static class CriticalQuickNotationParser
string line, string line,
int sortOrder, int sortOrder,
AffixLegend affixLegend, AffixLegend affixLegend,
List<string> validationErrors) List<string> validationErrors,
List<CriticalTokenReviewIssue> tokenReviewIssues)
{ {
var separatorIndex = line.IndexOf(':', StringComparison.Ordinal); var separatorIndex = line.IndexOf(':', StringComparison.Ordinal);
var conditionText = CriticalCellParserSupport.CollapseWhitespace(line[..separatorIndex]); var conditionText = CriticalCellParserSupport.CollapseWhitespace(line[..separatorIndex]);
@@ -87,17 +97,31 @@ public static class CriticalQuickNotationParser
foreach (var token in tokens) foreach (var token in tokens)
{ {
if (AffixEffectParser.TryParseAffixToken(token, affixLegend, out var parsedEffects, out var normalizedToken)) var parseResult = AffixEffectParser.ParseAffixToken(token, affixLegend);
if (parseResult.Effects.Count > 0)
{ {
seenAffix = true; seenAffix = true;
normalizedAffixTokens.Add(normalizedToken); normalizedAffixTokens.Add(parseResult.NormalizedToken);
effects.AddRange(parsedEffects); effects.AddRange(parseResult.Effects);
AddTokenReviewIssues(
tokenReviewIssues,
validationErrors,
scope: "branch",
conditionText,
originalToken: token,
parseResult);
continue; continue;
} }
if (seenAffix) if (seenAffix || LooksLikeAffixToken(token, affixLegend))
{ {
validationErrors.Add($"Branch '{conditionText}' has unrecognized affix token '{token}'."); AddTokenReviewIssues(
tokenReviewIssues,
validationErrors,
scope: "branch",
conditionText,
originalToken: token,
parseResult);
continue; continue;
} }
@@ -128,4 +152,72 @@ public static class CriticalQuickNotationParser
.Select(CriticalCellParserSupport.CollapseWhitespace) .Select(CriticalCellParserSupport.CollapseWhitespace)
.Where(token => !string.IsNullOrWhiteSpace(token)) .Where(token => !string.IsNullOrWhiteSpace(token))
.ToList(); .ToList();
private static void AddTokenReviewIssues(
List<CriticalTokenReviewIssue> tokenReviewIssues,
List<string> validationErrors,
string scope,
string? conditionText,
string originalToken,
AffixTokenParseResult parseResult)
{
if (parseResult.UnresolvedFragments.Count == 0)
{
return;
}
var reviewText = BuildReviewText(scope, conditionText, originalToken, parseResult);
var issue = new CriticalTokenReviewIssue(
scope,
conditionText,
CriticalCellParserSupport.CollapseWhitespace(originalToken),
reviewText);
tokenReviewIssues.Add(issue);
validationErrors.Add(reviewText);
}
private static string BuildReviewText(
string scope,
string? conditionText,
string originalToken,
AffixTokenParseResult parseResult)
{
var normalizedToken = CriticalCellParserSupport.CollapseWhitespace(originalToken);
var scopeLabel = scope == "branch"
? $"Branch '{conditionText}'"
: "Base content";
if (parseResult.Effects.Count == 0)
{
return $"{scopeLabel} has unrecognized affix token '{normalizedToken}'.";
}
var unresolvedText = string.Join(", ", parseResult.UnresolvedFragments.Select(fragment => $"'{fragment}'"));
var fragmentLabel = parseResult.UnresolvedFragments.Count == 1 ? "fragment" : "fragments";
return $"{scopeLabel} token '{normalizedToken}' still has unresolved {fragmentLabel} {unresolvedText}.";
}
private static bool LooksLikeAffixToken(string token, AffixLegend affixLegend)
{
var normalized = CriticalCellParserSupport.CollapseWhitespace(token);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
if (normalized.Any(char.IsDigit) ||
normalized.StartsWith('+') ||
normalized.StartsWith('-'))
{
return true;
}
if (normalized.Contains("pp", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return affixLegend.EffectSymbols.Any(symbol => normalized.Contains(symbol, StringComparison.Ordinal));
}
} }

View File

@@ -0,0 +1,7 @@
namespace RolemasterDb.CriticalParsing;
public sealed record CriticalTokenReviewIssue(
string Scope,
string? ConditionText,
string Token,
string ReviewText);

View File

@@ -77,6 +77,7 @@ public sealed class CriticalCellComparisonEvaluatorTests
"+10") "+10")
], ],
[], [],
[],
[]); []);
var differenceCount = CriticalCellComparisonEvaluator.GetDifferenceCount( var differenceCount = CriticalCellComparisonEvaluator.GetDifferenceCount(

View File

@@ -65,6 +65,53 @@ public sealed class CriticalCellReparseIntegrationTests
Assert.Empty(content.ValidationErrors); Assert.Empty(content.ValidationErrors);
} }
[Fact]
public void Shared_quick_notation_parser_surfaces_unknown_base_tokens_explicitly()
{
var legend = new AffixLegend(
new Dictionary<string, string>(StringComparer.Ordinal)
{
["π"] = AppCriticalEffectCodes.MustParryRounds
},
[],
supportsFoePenalty: false,
supportsAttackerBonus: false,
supportsPowerPointModifier: false);
var content = CriticalQuickNotationParser.Parse(
"Foe reels away from the blast.\r\n+5, mystery",
legend);
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.DirectHits && effect.ValueInteger == 5);
Assert.Single(content.TokenReviewIssues);
Assert.Equal("mystery", content.TokenReviewIssues[0].Token);
Assert.Contains("unrecognized affix token 'mystery'", content.TokenReviewIssues[0].ReviewText, StringComparison.Ordinal);
Assert.Contains(content.ValidationErrors, message => message.Contains("'mystery'", StringComparison.Ordinal));
}
[Fact]
public void Shared_quick_notation_parser_keeps_recognized_effects_when_a_token_is_only_partially_understood()
{
var legend = new AffixLegend(
new Dictionary<string, string>(StringComparer.Ordinal)
{
["π"] = AppCriticalEffectCodes.MustParryRounds
},
[],
supportsFoePenalty: false,
supportsAttackerBonus: false,
supportsPowerPointModifier: false);
var content = CriticalQuickNotationParser.Parse(
"Foe hesitates.\r\nπ?",
legend);
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1);
Assert.Single(content.TokenReviewIssues);
Assert.Equal("π?", content.TokenReviewIssues[0].Token);
Assert.Contains("unresolved fragment '?'", content.TokenReviewIssues[0].ReviewText, StringComparison.Ordinal);
}
[Fact] [Fact]
public void Shared_symbol_affix_parser_supports_negative_power_point_notation() public void Shared_symbol_affix_parser_supports_negative_power_point_notation()
{ {
@@ -176,6 +223,8 @@ public sealed class CriticalCellReparseIntegrationTests
branch.DescriptionText == "glancing blow." && branch.DescriptionText == "glancing blow." &&
branch.Effects.Any(effect => effect.EffectCode == AppCriticalEffectCodes.StunnedRounds && effect.DurationRounds == 2)); branch.Effects.Any(effect => effect.EffectCode == AppCriticalEffectCodes.StunnedRounds && effect.DurationRounds == 2));
Assert.Empty(response.ValidationMessages); Assert.Empty(response.ValidationMessages);
Assert.NotNull(response.GeneratedState);
Assert.Empty(response.GeneratedState!.TokenReviewItems);
} }
[Fact] [Fact]
@@ -322,6 +371,7 @@ public sealed class CriticalCellReparseIntegrationTests
response!.QuickParseInput.Replace("\r\n", "\n", StringComparison.Ordinal)); response!.QuickParseInput.Replace("\r\n", "\n", StringComparison.Ordinal));
Assert.NotNull(response.GeneratedState); Assert.NotNull(response.GeneratedState);
Assert.Contains(response.GeneratedState!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1); Assert.Contains(response.GeneratedState!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1);
Assert.Empty(response.GeneratedState.TokenReviewItems);
var reparsed = await lookupService.ReparseCriticalCellAsync( var reparsed = await lookupService.ReparseCriticalCellAsync(
"arcane-aether", "arcane-aether",
@@ -331,6 +381,32 @@ public sealed class CriticalCellReparseIntegrationTests
Assert.NotNull(reparsed); Assert.NotNull(reparsed);
Assert.NotNull(reparsed!.GeneratedState); Assert.NotNull(reparsed!.GeneratedState);
Assert.Contains(reparsed.GeneratedState!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1); Assert.Contains(reparsed.GeneratedState!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1);
Assert.Empty(reparsed.GeneratedState.TokenReviewItems);
}
[Fact]
public async Task Lookup_service_reparse_surfaces_unknown_tokens_in_generated_review()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-reparse-unknown-{Guid.NewGuid():N}.db");
await SeedSlashResultAsync(databasePath);
var lookupService = new LookupService(CreateDbContextFactory(databasePath));
var resultId = await GetSlashResultIdAsync(databasePath);
var response = await lookupService.ReparseCriticalCellAsync(
"slash",
resultId,
CreateEditorRequest(
"Imported OCR source",
"Strike to thigh.",
quickParseInput: "Strike to thigh.\n+10, mystery"));
Assert.NotNull(response);
Assert.NotNull(response!.GeneratedState);
Assert.Single(response.GeneratedState!.TokenReviewItems);
Assert.Equal("mystery", response.GeneratedState.TokenReviewItems[0].Token);
Assert.Contains("unrecognized affix token 'mystery'", response.GeneratedState.TokenReviewItems[0].ReviewText, StringComparison.Ordinal);
Assert.Contains(response.ValidationMessages, message => message.Contains("'mystery'", StringComparison.Ordinal));
} }
[Fact] [Fact]