Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions backend/FwLite/MiniLcm.Tests/Helpers/NfcTestData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ public static PartOfSpeech CreateNfcPartOfSpeech()
return new()
{
Id = Guid.NewGuid(),
Name = CreateNfcMultiString()
Name = CreateNfcMultiString(),
Predefined = true,
};
}

Expand All @@ -69,7 +70,8 @@ public static SemanticDomain CreateNfcSemanticDomain()
{
Id = Guid.NewGuid(),
Code = Nfc,
Name = CreateNfcMultiString()
Name = CreateNfcMultiString(),
Predefined = true,
};
}

Expand Down Expand Up @@ -105,11 +107,17 @@ public static Translation CreateNfcTranslation()
};
}

// Non-string scalars below are deliberately populated with non-default values so that
// identity-preservation assertions (e.g. BeEquivalentTo on the wrapper's output) actually
// exercise those fields. A default-valued field can't catch a normalizer that drops it.

public static ExampleSentence CreateNfcExampleSentence()
{
return new()
{
Id = Guid.NewGuid(),
Order = 2.5,
SenseId = Guid.NewGuid(),
Sentence = CreateNfcRichMultiString(),
Reference = CreateNfcRichString()
};
Expand All @@ -120,6 +128,8 @@ public static ExampleSentence CreateNfcExampleSentenceWithTranslations()
return new()
{
Id = Guid.NewGuid(),
Order = 2.5,
SenseId = Guid.NewGuid(),
Sentence = CreateNfcRichMultiString(),
Reference = CreateNfcRichString(),
Translations = [CreateNfcTranslation(), CreateNfcTranslation()]
Expand All @@ -131,20 +141,27 @@ public static Sense CreateNfcSense()
return new()
{
Id = Guid.NewGuid(),
Order = 1.5,
EntryId = Guid.NewGuid(),
PartOfSpeechId = Guid.NewGuid(),
Gloss = CreateNfcMultiString(),
Definition = CreateNfcRichMultiString()
};
}

public static Sense CreateNfcSenseWithExamples()
{
var pos = CreateNfcPartOfSpeech();
return new()
{
Id = Guid.NewGuid(),
Order = 1.5,
EntryId = Guid.NewGuid(),
Gloss = CreateNfcMultiString(),
Definition = CreateNfcRichMultiString(),
SemanticDomains = [CreateNfcSemanticDomain()],
PartOfSpeech = CreateNfcPartOfSpeech(),
PartOfSpeech = pos,
PartOfSpeechId = pos.Id,
ExampleSentences = [CreateNfcExampleSentenceWithTranslations()]
};
}
Expand All @@ -154,8 +171,10 @@ public static ComplexFormComponent CreateNfcComplexFormComponent()
return new()
{
Id = Guid.NewGuid(),
Order = 1.5,
ComplexFormEntryId = Guid.NewGuid(),
ComponentEntryId = Guid.NewGuid(),
ComponentSenseId = Guid.NewGuid(),
ComplexFormHeadword = Nfc,
ComponentHeadword = Nfc
};
Expand All @@ -166,6 +185,8 @@ public static Entry CreateNfcEntry()
return new()
{
Id = Guid.NewGuid(),
HomographNumber = 3,
MorphType = MorphTypeKind.Root,
LexemeForm = CreateNfcMultiString(),
CitationForm = CreateNfcMultiString(),
LiteralMeaning = CreateNfcRichMultiString(),
Expand All @@ -178,6 +199,8 @@ public static Entry CreateNfcEntryWithSenses()
return new()
{
Id = Guid.NewGuid(),
HomographNumber = 3,
MorphType = MorphTypeKind.Root,
LexemeForm = CreateNfcMultiString(),
CitationForm = CreateNfcMultiString(),
LiteralMeaning = CreateNfcRichMultiString(),
Expand All @@ -191,6 +214,8 @@ public static Entry CreateNfcEntryWithComponents()
return new()
{
Id = Guid.NewGuid(),
HomographNumber = 3,
MorphType = MorphTypeKind.Root,
LexemeForm = CreateNfcMultiString(),
CitationForm = CreateNfcMultiString(),
LiteralMeaning = CreateNfcRichMultiString(),
Expand Down
107 changes: 76 additions & 31 deletions backend/FwLite/MiniLcm.Tests/Helpers/NormalizationAssert.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,34 @@
using System.Collections;
using System.Reflection;
using System.Text;
using FluentAssertions.Equivalency;

namespace MiniLcm.Tests.Helpers;

public static class NormalizationEquivalency
{
/// <summary>
/// Configure BeEquivalentTo so strings compare equal modulo NFC/NFD normalization,
/// catching non-string fields the wrapper drops (HomographNumber, Order, etc.)
/// that the form-only check ignores.
/// </summary>
public static EquivalencyOptions<T> NormalizedStrings<T>(this EquivalencyOptions<T> options)
{
return options
.Using<string>(ctx =>
{
if (ctx.Subject is null || ctx.Expectation is null)
{
ctx.Subject.Should().Be(ctx.Expectation);
return;
}
ctx.Subject.Normalize(NormalizationForm.FormD)
.Should().Be(ctx.Expectation.Normalize(NormalizationForm.FormD));
})
.WhenTypeIs<string>();
}
}

/// <summary>
/// For verifying that every string-bearing property of an object is normalized (NFC or NFD).
/// </summary>
Expand All @@ -22,73 +47,85 @@ public static class NormalizationAssert
],
};

public static void AssertAllNfc(object? obj)
public static void AssertAllDecomposed(object? obj)
{
Assert(obj, NormalizationForm.FormC);
Assert(obj, Nfd);
}

public static void AssertAllNfd(object? obj)
/// <summary>
/// Strict NFC plus a non-triviality check: every string must differ from its NFD form.
/// Catches test data, which is byte-identical in NFC and NFD (e.g. ASCII) and would
/// silently bypass the normalizer.
/// </summary>
public static void AssertAllDecomposable(object? obj)
{
Assert(obj, NormalizationForm.FormD);
Assert(obj, DecomposableNfc);
}

public static bool IsAllNfd(object obj)
/// <summary>
/// For verifying the output of the write-normalization wrapper:
/// asserts every string is NFD AND that no non-string field was dropped or mutated
/// (BeEquivalentTo on the input, with NFC≡NFD string equivalence).
/// Catches the field-drop regression class that pure string-form checks ignore.
/// </summary>
public static void AssertNormalizedToNfd<T>(T? captured, T input) where T : class
{
return FindIssues(obj, NormalizationForm.FormD).Count == 0;
captured.Should().NotBeNull();
AssertAllDecomposed(captured);
captured.Should().BeEquivalentTo(input, opts => opts.NormalizedStrings());
}

private static void Assert(object? obj, NormalizationForm form)
private static void Assert(object? obj, StringCheck check)
{
if (obj is null) throw new Xunit.Sdk.XunitException("Expected object to be non-null but was null");
var issues = FindIssues(obj, form);
var issues = FindIssues(obj, check);
if (issues.Count == 0) return;
var name = FormName(form);
throw new Xunit.Sdk.XunitException(
$"Expected all normalizable properties to contain {name} strings, but found issues:\n" +
$"Expected all normalizable properties to contain {check.Label} strings, but found issues:\n" +
string.Join("\n", issues.Select(i => " - " + i))
);
}

private static List<string> FindIssues(object obj, NormalizationForm form)
private static List<string> FindIssues(object obj, StringCheck check)
{
var issues = new List<string>();
Visit(obj, "", form, issues);
Visit(obj, "", check, issues);
return issues;
}

private static void Visit(object? obj, string path, NormalizationForm form, List<string> issues)
private static void Visit(object? obj, string path, StringCheck check, List<string> issues)
{
switch (obj)
{
case null:
return;
case string s:
CheckString(s, path, form, issues);
CheckString(s, path, check, issues);
return;
case MultiString ms:
if (ms.Values.Count == 0) issues.Add($"{path}: MultiString has no values (must have at least one for testing)");
foreach (var (key, value) in ms.Values) CheckString(value, $"{path}.Values[{key}]", form, issues);
foreach (var (key, value) in ms.Values) CheckString(value, $"{path}.Values[{key}]", check, issues);
return;
case RichString rs:
if (rs.Spans.Count == 0) issues.Add($"{path}: RichString has no spans (must have at least one for testing)");
for (var i = 0; i < rs.Spans.Count; i++) CheckString(rs.Spans[i].Text, $"{path}.Spans[{i}].Text", form, issues);
for (var i = 0; i < rs.Spans.Count; i++) CheckString(rs.Spans[i].Text, $"{path}.Spans[{i}].Text", check, issues);
return;
case RichMultiString rms:
if (rms.Count == 0) issues.Add($"{path}: RichMultiString has no values (must have at least one for testing)");
foreach (var (key, value) in rms) Visit(value, $"{path}[{key}]", form, issues);
foreach (var (key, value) in rms) Visit(value, $"{path}[{key}]", check, issues);
return;
case IEnumerable seq:
var i2 = 0;
foreach (var item in seq) Visit(item, $"{path}[{i2++}]", form, issues);
foreach (var item in seq) Visit(item, $"{path}[{i2++}]", check, issues);
return;
default:
break;
}

VisitModelProperties(obj, path, form, issues);
VisitModelProperties(obj, path, check, issues);
}

private static void VisitModelProperties(object obj, string path, NormalizationForm form, List<string> issues)
private static void VisitModelProperties(object obj, string path, StringCheck check, List<string> issues)
{
var type = obj.GetType();
if (type.Namespace?.StartsWith("MiniLcm.Models", StringComparison.Ordinal) != true)
Expand All @@ -104,7 +141,7 @@ private static void VisitModelProperties(object obj, string path, NormalizationF
if (value is null) continue;

var propPath = string.IsNullOrEmpty(path) ? prop.Name : $"{path}.{prop.Name}";
Visit(value, propPath, form, issues);
Visit(value, propPath, check, issues);
}
}

Expand All @@ -116,22 +153,30 @@ private static bool IsIgnoredType(Type type)
underlying == typeof(DateTimeOffset) || underlying == typeof(decimal);
}

private static void CheckString(string? value, string path, NormalizationForm form, List<string> issues)
private static void CheckString(string? value, string path, StringCheck check, List<string> issues)
{
if (string.IsNullOrEmpty(value))
{
issues.Add($"{path}: string is null or empty (must have a value for testing)");
return;
}
if (!value.IsNormalized(form))
{
var name = FormName(form);
issues.Add($"{path}: expected {name} but \"{value}\" is not {name}-normalized");
}
var issue = check.Validate(value);
if (issue != null) issues.Add($"{path}: {issue}");
}

private static string FormName(NormalizationForm form)
{
return form == NormalizationForm.FormC ? "NFC" : "NFD";
}
// A check pairs the failure-header label with a validator that returns an issue for one string
// (or null if it is fine), so a caller can't mismatch label and logic.
private sealed record StringCheck(string Label, Func<string, string?> Validate);

private static readonly StringCheck Nfd = new("NFD", value =>
value.IsNormalized(NormalizationForm.FormD)
? null
: $"expected NFD but \"{value}\" is not NFD-normalized");

private static readonly StringCheck DecomposableNfc = new("decomposable NFC", value =>
!value.IsNormalized(NormalizationForm.FormC)
? $"expected NFC but \"{value}\" is not NFC-normalized"
: value == value.Normalize(NormalizationForm.FormD)
? $"\"{value}\" is trivially NFC (identical to its NFD form); use content that actually exercises normalization"
: null);
}
Loading
Loading