diff --git a/backend/FwLite/MiniLcm.Tests/Helpers/NfcTestData.cs b/backend/FwLite/MiniLcm.Tests/Helpers/NfcTestData.cs
index 61144e6a4f..bd727c2293 100644
--- a/backend/FwLite/MiniLcm.Tests/Helpers/NfcTestData.cs
+++ b/backend/FwLite/MiniLcm.Tests/Helpers/NfcTestData.cs
@@ -50,7 +50,8 @@ public static PartOfSpeech CreateNfcPartOfSpeech()
return new()
{
Id = Guid.NewGuid(),
- Name = CreateNfcMultiString()
+ Name = CreateNfcMultiString(),
+ Predefined = true,
};
}
@@ -69,7 +70,8 @@ public static SemanticDomain CreateNfcSemanticDomain()
{
Id = Guid.NewGuid(),
Code = Nfc,
- Name = CreateNfcMultiString()
+ Name = CreateNfcMultiString(),
+ Predefined = true,
};
}
@@ -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()
};
@@ -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()]
@@ -131,6 +141,9 @@ public static Sense CreateNfcSense()
return new()
{
Id = Guid.NewGuid(),
+ Order = 1.5,
+ EntryId = Guid.NewGuid(),
+ PartOfSpeechId = Guid.NewGuid(),
Gloss = CreateNfcMultiString(),
Definition = CreateNfcRichMultiString()
};
@@ -138,13 +151,17 @@ public static Sense CreateNfcSense()
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()]
};
}
@@ -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
};
@@ -166,6 +185,8 @@ public static Entry CreateNfcEntry()
return new()
{
Id = Guid.NewGuid(),
+ HomographNumber = 3,
+ MorphType = MorphTypeKind.Root,
LexemeForm = CreateNfcMultiString(),
CitationForm = CreateNfcMultiString(),
LiteralMeaning = CreateNfcRichMultiString(),
@@ -178,6 +199,8 @@ public static Entry CreateNfcEntryWithSenses()
return new()
{
Id = Guid.NewGuid(),
+ HomographNumber = 3,
+ MorphType = MorphTypeKind.Root,
LexemeForm = CreateNfcMultiString(),
CitationForm = CreateNfcMultiString(),
LiteralMeaning = CreateNfcRichMultiString(),
@@ -191,6 +214,8 @@ public static Entry CreateNfcEntryWithComponents()
return new()
{
Id = Guid.NewGuid(),
+ HomographNumber = 3,
+ MorphType = MorphTypeKind.Root,
LexemeForm = CreateNfcMultiString(),
CitationForm = CreateNfcMultiString(),
LiteralMeaning = CreateNfcRichMultiString(),
diff --git a/backend/FwLite/MiniLcm.Tests/Helpers/NormalizationAssert.cs b/backend/FwLite/MiniLcm.Tests/Helpers/NormalizationAssert.cs
index a0b048464b..4300daf9ab 100644
--- a/backend/FwLite/MiniLcm.Tests/Helpers/NormalizationAssert.cs
+++ b/backend/FwLite/MiniLcm.Tests/Helpers/NormalizationAssert.cs
@@ -1,9 +1,34 @@
using System.Collections;
using System.Reflection;
using System.Text;
+using FluentAssertions.Equivalency;
namespace MiniLcm.Tests.Helpers;
+public static class NormalizationEquivalency
+{
+ ///
+ /// 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.
+ ///
+ public static EquivalencyOptions NormalizedStrings(this EquivalencyOptions options)
+ {
+ return options
+ .Using(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();
+ }
+}
+
///
/// For verifying that every string-bearing property of an object is normalized (NFC or NFD).
///
@@ -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)
+ ///
+ /// 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.
+ ///
+ public static void AssertAllDecomposable(object? obj)
{
- Assert(obj, NormalizationForm.FormD);
+ Assert(obj, DecomposableNfc);
}
- public static bool IsAllNfd(object obj)
+ ///
+ /// 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.
+ ///
+ public static void AssertNormalizedToNfd(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 FindIssues(object obj, NormalizationForm form)
+ private static List FindIssues(object obj, StringCheck check)
{
var issues = new List();
- Visit(obj, "", form, issues);
+ Visit(obj, "", check, issues);
return issues;
}
- private static void Visit(object? obj, string path, NormalizationForm form, List issues)
+ private static void Visit(object? obj, string path, StringCheck check, List 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 issues)
+ private static void VisitModelProperties(object obj, string path, StringCheck check, List issues)
{
var type = obj.GetType();
if (type.Namespace?.StartsWith("MiniLcm.Models", StringComparison.Ordinal) != true)
@@ -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);
}
}
@@ -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 issues)
+ private static void CheckString(string? value, string path, StringCheck check, List 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 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);
}
diff --git a/backend/FwLite/MiniLcm.Tests/WriteNormalizationTests.cs b/backend/FwLite/MiniLcm.Tests/WriteNormalizationTests.cs
index 78e7ed066d..0b76444495 100644
--- a/backend/FwLite/MiniLcm.Tests/WriteNormalizationTests.cs
+++ b/backend/FwLite/MiniLcm.Tests/WriteNormalizationTests.cs
@@ -84,8 +84,7 @@ public async Task CreatePartOfSpeech_NormalizesToNfd()
await _normalizingApi.CreatePartOfSpeech(pos);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, pos);
}
[Fact]
@@ -103,8 +102,8 @@ public async Task UpdatePartOfSpeech_BeforeAfter_NormalizesBothToNfd()
await _normalizingApi.UpdatePartOfSpeech(before, after);
- AssertAllNfd(capturedBefore);
- AssertAllNfd(capturedAfter);
+ AssertNormalizedToNfd(capturedBefore, before);
+ AssertNormalizedToNfd(capturedAfter, after);
}
#endregion
@@ -124,8 +123,7 @@ public async Task CreatePublication_NormalizesToNfd()
await _normalizingApi.CreatePublication(pub);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, pub);
}
[Fact]
@@ -143,8 +141,8 @@ public async Task UpdatePublication_BeforeAfter_NormalizesBothToNfd()
await _normalizingApi.UpdatePublication(before, after);
- AssertAllNfd(capturedBefore);
- AssertAllNfd(capturedAfter);
+ AssertNormalizedToNfd(capturedBefore, before);
+ AssertNormalizedToNfd(capturedAfter, after);
}
#endregion
@@ -164,8 +162,7 @@ public async Task CreateSemanticDomain_NormalizesToNfd()
await _normalizingApi.CreateSemanticDomain(sd);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, sd);
}
[Fact]
@@ -183,8 +180,8 @@ public async Task UpdateSemanticDomain_BeforeAfter_NormalizesBothToNfd()
await _normalizingApi.UpdateSemanticDomain(before, after);
- AssertAllNfd(capturedBefore);
- AssertAllNfd(capturedAfter);
+ AssertNormalizedToNfd(capturedBefore, before);
+ AssertNormalizedToNfd(capturedAfter, after);
}
[Fact]
@@ -200,8 +197,7 @@ public async Task AddSemanticDomainToSense_NormalizesToNfd()
await _normalizingApi.AddSemanticDomainToSense(Guid.NewGuid(), sd);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, sd);
}
[Fact]
@@ -219,8 +215,9 @@ public async Task BulkImportSemanticDomains_NormalizesToNfd()
await _normalizingApi.BulkImportSemanticDomains(domains.ToAsyncEnumerable());
- captured.Should().HaveCount(2);
- AssertAllNfd(captured);
+ captured.Should().HaveCount(domains.Length);
+ AssertAllDecomposed(captured);
+ captured.Should().BeEquivalentTo(domains, opts => opts.NormalizedStrings().WithStrictOrdering());
}
#endregion
@@ -240,8 +237,7 @@ public async Task CreateComplexFormType_NormalizesToNfd()
await _normalizingApi.CreateComplexFormType(cft);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, cft);
}
[Fact]
@@ -259,8 +255,8 @@ public async Task UpdateComplexFormType_BeforeAfter_NormalizesBothToNfd()
await _normalizingApi.UpdateComplexFormType(before, after);
- AssertAllNfd(capturedBefore);
- AssertAllNfd(capturedAfter);
+ AssertNormalizedToNfd(capturedBefore, before);
+ AssertNormalizedToNfd(capturedAfter, after);
}
#endregion
@@ -282,8 +278,8 @@ public async Task UpdateMorphType_BeforeAfter_NormalizesBothToNfd()
await _normalizingApi.UpdateMorphType(before, after);
- AssertAllNfd(capturedBefore);
- AssertAllNfd(capturedAfter);
+ AssertNormalizedToNfd(capturedBefore, before);
+ AssertNormalizedToNfd(capturedAfter, after);
}
[Fact]
@@ -304,7 +300,7 @@ public async Task UpdateMorphType_JsonPatch_NormalizesToNfd()
captured.Should().NotBeNull();
var byPath = captured.Patch.Operations.ToDictionary(o => o.Path!, o => o.Value);
- AssertAllNfd(byPath["/Name"].Should().BeOfType().Subject);
+ AssertAllDecomposed(byPath["/Name"].Should().BeOfType().Subject);
byPath["/Prefix"].Should().Be(NfcTestData.Nfd);
byPath["/Postfix"].Should().Be(NfcTestData.Nfd);
}
@@ -326,8 +322,7 @@ public async Task CreateEntry_NormalizesToNfd()
await _normalizingApi.CreateEntry(entry);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, entry);
}
[Fact]
@@ -345,8 +340,8 @@ public async Task UpdateEntry_BeforeAfter_NormalizesBothToNfd()
await _normalizingApi.UpdateEntry(before, after);
- AssertAllNfd(capturedBefore);
- AssertAllNfd(capturedAfter);
+ AssertNormalizedToNfd(capturedBefore, before);
+ AssertNormalizedToNfd(capturedAfter, after);
}
[Fact]
@@ -362,8 +357,7 @@ public async Task CreateEntry_WithNestedSenses_NormalizesToNfd()
await _normalizingApi.CreateEntry(entry);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, entry);
}
[Fact]
@@ -379,8 +373,7 @@ public async Task CreateEntry_WithComplexFormComponents_NormalizesToNfd()
await _normalizingApi.CreateEntry(entry);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, entry);
}
[Fact]
@@ -398,8 +391,9 @@ public async Task BulkCreateEntries_NormalizesToNfd()
await _normalizingApi.BulkCreateEntries(entries.ToAsyncEnumerable());
- captured.Should().HaveCount(2);
- AssertAllNfd(captured);
+ captured.Should().HaveCount(entries.Length);
+ AssertAllDecomposed(captured);
+ captured.Should().BeEquivalentTo(entries, opts => opts.NormalizedStrings().WithStrictOrdering());
}
#endregion
@@ -425,7 +419,7 @@ public async Task UpdateEntry_JsonPatch_NormalizesValuesToNfd()
captured.Should().NotBeNull();
captured.Should().NotBeSameAs(update); // rebuilt because Entry path normalizes
- AssertAllPatchValuesNfd(captured);
+ AssertAllPatchValuesDecomposed(captured);
}
[Fact]
@@ -475,16 +469,16 @@ public async Task UpdateWritingSystem_JsonPatch_DoesNotNormalize()
}
///
- /// Asserts every non-null operation value in the patch is NFD. Delegates to
- /// , which already understands string,
+ /// Asserts every non-null operation value in the patch is decomposed. Delegates to
+ /// , which already understands string,
/// string[], MultiString, RichString, and RichMultiString — so failures report a property path.
///
- private static void AssertAllPatchValuesNfd(UpdateObjectInput update) where T : class
+ private static void AssertAllPatchValuesDecomposed(UpdateObjectInput update) where T : class
{
update.Patch.Operations.Should().NotBeEmpty();
foreach (var op in update.Patch.Operations)
{
- if (op.Value is not null) AssertAllNfd(op.Value);
+ if (op.Value is not null) AssertAllDecomposed(op.Value);
}
}
@@ -506,8 +500,7 @@ public async Task CreateComplexFormComponent_NormalizesToNfd()
await _normalizingApi.CreateComplexFormComponent(cfc);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, cfc);
}
#endregion
@@ -527,8 +520,7 @@ public async Task CreateSense_NormalizesToNfd()
await _normalizingApi.CreateSense(Guid.NewGuid(), sense);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, sense);
}
[Fact]
@@ -547,8 +539,8 @@ public async Task UpdateSense_BeforeAfter_NormalizesBothToNfd()
await _normalizingApi.UpdateSense(entryId, before, after);
- AssertAllNfd(capturedBefore);
- AssertAllNfd(capturedAfter);
+ AssertNormalizedToNfd(capturedBefore, before);
+ AssertNormalizedToNfd(capturedAfter, after);
}
[Fact]
@@ -564,8 +556,7 @@ public async Task CreateSense_WithNestedExampleSentences_NormalizesToNfd()
await _normalizingApi.CreateSense(Guid.NewGuid(), sense);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, sense);
}
#endregion
@@ -586,8 +577,7 @@ public async Task CreateExampleSentence_NormalizesToNfd()
await _normalizingApi.CreateExampleSentence(Guid.NewGuid(), Guid.NewGuid(), example);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, example);
}
[Fact]
@@ -610,8 +600,8 @@ public async Task UpdateExampleSentence_BeforeAfter_NormalizesBothToNfd()
await _normalizingApi.UpdateExampleSentence(entryId, senseId, before, after);
- AssertAllNfd(capturedBefore);
- AssertAllNfd(capturedAfter);
+ AssertNormalizedToNfd(capturedBefore, before);
+ AssertNormalizedToNfd(capturedAfter, after);
}
[Fact]
@@ -628,8 +618,7 @@ public async Task CreateExampleSentence_WithTranslations_NormalizesToNfd()
await _normalizingApi.CreateExampleSentence(Guid.NewGuid(), Guid.NewGuid(), example);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, example);
}
#endregion
@@ -650,8 +639,7 @@ public async Task AddTranslation_NormalizesToNfd()
await _normalizingApi.AddTranslation(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), translation);
- captured.Should().NotBeNull();
- AssertAllNfd(captured);
+ AssertNormalizedToNfd(captured, translation);
}
#endregion
@@ -661,50 +649,28 @@ public async Task AddTranslation_NormalizesToNfd()
public class NormalizationAssertTests
{
[Fact]
- public void AllNfcFactories_ProduceNfcData()
- {
- AssertAllNfc(NfcTestData.CreateNfcWritingSystem());
- AssertAllNfc(NfcTestData.CreateNfcPartOfSpeech());
- AssertAllNfc(NfcTestData.CreateNfcPublication());
- AssertAllNfc(NfcTestData.CreateNfcSemanticDomain());
- AssertAllNfc(NfcTestData.CreateNfcComplexFormType());
- AssertAllNfc(NfcTestData.CreateNfcMorphType());
- AssertAllNfc(NfcTestData.CreateNfcTranslation());
- AssertAllNfc(NfcTestData.CreateNfcExampleSentence());
- AssertAllNfc(NfcTestData.CreateNfcExampleSentenceWithTranslations());
- AssertAllNfc(NfcTestData.CreateNfcSense());
- AssertAllNfc(NfcTestData.CreateNfcSenseWithExamples());
- AssertAllNfc(NfcTestData.CreateNfcComplexFormComponent());
- AssertAllNfc(NfcTestData.CreateNfcEntry());
- AssertAllNfc(NfcTestData.CreateNfcEntryWithSenses());
- AssertAllNfc(NfcTestData.CreateNfcEntryWithComponents());
- }
-
- [Fact]
- public void AssertAllNfc_WithNfcData_DoesNotThrow()
- {
- AssertAllNfc(NfcTestData.CreateNfcEntry());
- }
-
- [Fact]
- public void AssertAllNfc_WithNfdData_Throws()
+ public void AllNfcFactories_ProduceDecomposableData()
{
- var entry = new Entry
- {
- Id = Guid.NewGuid(),
- LexemeForm = new MultiString { Values = { { "en", NfcTestData.Nfd } } }, // NFD should fail
- CitationForm = new MultiString { Values = { { "en", NfcTestData.Nfc } } },
- LiteralMeaning = new RichMultiString { { "en", new RichString(NfcTestData.Nfc) } },
- Note = new RichMultiString { { "en", new RichString(NfcTestData.Nfc) } }
- };
-
- var act = () => AssertAllNfc(entry);
-
- act.Should().Throw().WithMessage("*NFC*");
+ // WritingSystem is intentionally omitted: its string fields are not normalized
+ // (see NormalizationAssert.SkippedProperties), so AssertAllDecomposable would assert nothing.
+ AssertAllDecomposable(NfcTestData.CreateNfcPartOfSpeech());
+ AssertAllDecomposable(NfcTestData.CreateNfcPublication());
+ AssertAllDecomposable(NfcTestData.CreateNfcSemanticDomain());
+ AssertAllDecomposable(NfcTestData.CreateNfcComplexFormType());
+ AssertAllDecomposable(NfcTestData.CreateNfcMorphType());
+ AssertAllDecomposable(NfcTestData.CreateNfcTranslation());
+ AssertAllDecomposable(NfcTestData.CreateNfcExampleSentence());
+ AssertAllDecomposable(NfcTestData.CreateNfcExampleSentenceWithTranslations());
+ AssertAllDecomposable(NfcTestData.CreateNfcSense());
+ AssertAllDecomposable(NfcTestData.CreateNfcSenseWithExamples());
+ AssertAllDecomposable(NfcTestData.CreateNfcComplexFormComponent());
+ AssertAllDecomposable(NfcTestData.CreateNfcEntry());
+ AssertAllDecomposable(NfcTestData.CreateNfcEntryWithSenses());
+ AssertAllDecomposable(NfcTestData.CreateNfcEntryWithComponents());
}
[Fact]
- public void AssertAllNfd_WithNfdData_DoesNotThrow()
+ public void AssertAllDecomposed_WithDecomposedData_DoesNotThrow()
{
var entry = new Entry
{
@@ -715,34 +681,38 @@ public void AssertAllNfd_WithNfdData_DoesNotThrow()
Note = new RichMultiString { { "en", new RichString(NfcTestData.Nfd) } }
};
- AssertAllNfd(entry);
+ AssertAllDecomposed(entry);
}
[Fact]
- public void AssertAllNfd_WithNfcData_Throws()
+ public void AssertAllDecomposed_WithComposedData_Throws()
{
- var act = () => AssertAllNfd(NfcTestData.CreateNfcEntry());
+ var act = () => AssertAllDecomposed(NfcTestData.CreateNfcEntry());
act.Should().Throw().WithMessage("*NFD*");
}
[Fact]
- public void AssertAllNfc_WithEmptyMultiString_Throws()
+ public void AssertAllDecomposable_WithEmptyMultiString_Throws()
{
var entry = new Entry
{
Id = Guid.NewGuid(),
- LexemeForm = [], // Empty - should fail
- CitationForm = NfcTestData.CreateNfcMultiString()
+ LexemeForm = [], // Empty MultiString — the only expected issue
+ CitationForm = NfcTestData.CreateNfcMultiString(),
+ LiteralMeaning = NfcTestData.CreateNfcRichMultiString(),
+ Note = NfcTestData.CreateNfcRichMultiString()
};
- var act = () => AssertAllNfc(entry);
+ var act = () => AssertAllDecomposable(entry);
- act.Should().Throw().WithMessage("*no values*");
+ // Scope to LexemeForm so this exercises the empty-MultiString check specifically;
+ // an empty RichMultiString would also report "no values".
+ act.Should().Throw().WithMessage("*LexemeForm*no values*");
}
[Fact]
- public void AssertAllNfc_WithNestedNfdData_Throws()
+ public void AssertAllDecomposable_WithNestedNfdData_Throws()
{
var entry = new Entry
{
@@ -762,22 +732,26 @@ public void AssertAllNfc_WithNestedNfdData_Throws()
]
};
- var act = () => AssertAllNfc(entry);
+ var act = () => AssertAllDecomposable(entry);
act.Should().Throw().WithMessage("*Senses*Gloss*");
}
[Fact]
- public void IsAllNfd_WithNfdData_ReturnsTrue()
+ public void AssertAllDecomposable_WithAsciiData_Throws()
{
- var multiString = new MultiString { Values = { { "en", NfcTestData.Nfd } } };
+ var entry = new Entry
+ {
+ Id = Guid.NewGuid(),
+ LexemeForm = new MultiString { Values = { { "en", "naive" } } }, // ASCII -> trivially NFC
+ CitationForm = NfcTestData.CreateNfcMultiString(),
+ LiteralMeaning = NfcTestData.CreateNfcRichMultiString(),
+ Note = NfcTestData.CreateNfcRichMultiString()
+ };
- IsAllNfd(multiString).Should().BeTrue();
- }
+ var act = () => AssertAllDecomposable(entry);
- [Fact]
- public void IsAllNfd_WithNfcData_ReturnsFalse()
- {
- IsAllNfd(NfcTestData.CreateNfcMultiString()).Should().BeFalse();
+ act.Should().Throw().WithMessage("*trivially*");
}
+
}
diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs
index 81cbe54dc2..f4ce83daa6 100644
--- a/backend/FwLite/MiniLcm/Models/Entry.cs
+++ b/backend/FwLite/MiniLcm/Models/Entry.cs
@@ -78,7 +78,7 @@ public Entry Copy()
[
..ComplexFormTypes.Select(cft => cft.Copy())
],
- PublishIn = [ ..PublishIn.Select(p => (Publication)p.Copy())]
+ PublishIn = [ ..PublishIn.Select(p => p.Copy())]
};
}
diff --git a/backend/FwLite/MiniLcm/Models/Publication.cs b/backend/FwLite/MiniLcm/Models/Publication.cs
index b13545622d..5f7d5db9b3 100644
--- a/backend/FwLite/MiniLcm/Models/Publication.cs
+++ b/backend/FwLite/MiniLcm/Models/Publication.cs
@@ -1,6 +1,6 @@
namespace MiniLcm.Models;
-public class Publication : IPossibility
+public class Publication : IPossibility, IObjectWithId
{
public required Guid Id { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
@@ -14,7 +14,7 @@ public void RemoveReference(Guid id, DateTimeOffset time)
return;
}
- public IObjectWithId Copy()
+ public Publication Copy()
{
return new Publication()
{
diff --git a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs
index 27a368fb30..aa9f030172 100644
--- a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs
+++ b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs
@@ -34,6 +34,9 @@ public IMiniLcmApi Create(IMiniLcmApi api)
/// Non-normalized data that is already persisted (before this wrapper was introduced) will be normalized by LibLcm. That's not something we want to fix here.
/// - Properties that liblcm/FieldWorks does not NFD-normalize are passed through unchanged.
/// Currently only WritingSystem properties.
+/// - Each normalizer starts from the model's own Copy() (or `with` for records) and overwrites only the
+/// text-bearing fields, so non-text fields — including any added later — are preserved automatically rather
+/// than re-listed here. Copy() completeness is enforced by LcmCrdt.Tests.EntityCopyMethodTests.
/// - JsonPatch overloads normalize string-ish values best-effort (string, RichString, MultiString, RichMultiString).
/// JsonElement values are only normalized when they are simple strings; complex JSON values are left as-is
/// to avoid guessing the target type.
@@ -99,13 +102,9 @@ public Task DeletePartOfSpeech(Guid id)
private static PartOfSpeech NormalizePartOfSpeech(PartOfSpeech pos)
{
- return new PartOfSpeech
- {
- Id = pos.Id,
- Name = StringNormalizer.Normalize(pos.Name),
- DeletedAt = pos.DeletedAt,
- Predefined = pos.Predefined
- };
+ var copy = pos.Copy();
+ copy.Name = StringNormalizer.Normalize(pos.Name);
+ return copy;
}
#endregion
@@ -135,12 +134,9 @@ public Task DeletePublication(Guid id)
private static Publication NormalizePublication(Publication pub)
{
- return new Publication
- {
- Id = pub.Id,
- Name = StringNormalizer.Normalize(pub.Name),
- DeletedAt = pub.DeletedAt
- };
+ var copy = pub.Copy();
+ copy.Name = StringNormalizer.Normalize(pub.Name);
+ return copy;
}
#endregion
@@ -170,14 +166,10 @@ public Task DeleteSemanticDomain(Guid id)
private static SemanticDomain NormalizeSemanticDomain(SemanticDomain sd)
{
- return new SemanticDomain
- {
- Id = sd.Id,
- Name = StringNormalizer.Normalize(sd.Name),
- Code = StringNormalizer.Normalize(sd.Code), // yes, LibLcm normalizes this too
- DeletedAt = sd.DeletedAt,
- Predefined = sd.Predefined
- };
+ var copy = sd.Copy();
+ copy.Name = StringNormalizer.Normalize(sd.Name);
+ copy.Code = StringNormalizer.Normalize(sd.Code); // yes, LibLcm normalizes this too
+ return copy;
}
#endregion
@@ -230,18 +222,13 @@ public async Task UpdateMorphType(MorphType before, MorphType after,
private static MorphType NormalizeMorphType(MorphType mt)
{
- return new MorphType
- {
- Id = mt.Id,
- Kind = mt.Kind,
- Name = StringNormalizer.Normalize(mt.Name),
- Abbreviation = StringNormalizer.Normalize(mt.Abbreviation),
- Description = StringNormalizer.Normalize(mt.Description),
- Prefix = StringNormalizer.Normalize(mt.Prefix),
- Postfix = StringNormalizer.Normalize(mt.Postfix),
- SecondaryOrder = mt.SecondaryOrder,
- DeletedAt = mt.DeletedAt
- };
+ var copy = mt.Copy();
+ copy.Name = StringNormalizer.Normalize(mt.Name);
+ copy.Abbreviation = StringNormalizer.Normalize(mt.Abbreviation);
+ copy.Description = StringNormalizer.Normalize(mt.Description);
+ copy.Prefix = StringNormalizer.Normalize(mt.Prefix);
+ copy.Postfix = StringNormalizer.Normalize(mt.Postfix);
+ return copy;
}
#endregion
@@ -374,19 +361,13 @@ public Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId)
private static Sense NormalizeSense(Sense sense)
{
- return new Sense
- {
- Id = sense.Id,
- Order = sense.Order,
- DeletedAt = sense.DeletedAt,
- EntryId = sense.EntryId,
- Definition = StringNormalizer.Normalize(sense.Definition),
- Gloss = StringNormalizer.Normalize(sense.Gloss),
- PartOfSpeech = sense.PartOfSpeech is not null ? NormalizePartOfSpeech(sense.PartOfSpeech) : null,
- PartOfSpeechId = sense.PartOfSpeechId,
- SemanticDomains = [.. sense.SemanticDomains.Select(NormalizeSemanticDomain)],
- ExampleSentences = [.. sense.ExampleSentences.Select(NormalizeExampleSentence)]
- };
+ var copy = sense.Copy();
+ copy.Definition = StringNormalizer.Normalize(sense.Definition);
+ copy.Gloss = StringNormalizer.Normalize(sense.Gloss);
+ copy.PartOfSpeech = sense.PartOfSpeech is not null ? NormalizePartOfSpeech(sense.PartOfSpeech) : null;
+ copy.SemanticDomains = [.. sense.SemanticDomains.Select(NormalizeSemanticDomain)];
+ copy.ExampleSentences = [.. sense.ExampleSentences.Select(NormalizeExampleSentence)];
+ return copy;
}
#endregion
@@ -437,25 +418,18 @@ public Task UpdateTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId
private static ExampleSentence NormalizeExampleSentence(ExampleSentence example)
{
- return new ExampleSentence
- {
- Id = example.Id,
- Order = example.Order,
- DeletedAt = example.DeletedAt,
- SenseId = example.SenseId,
- Sentence = StringNormalizer.Normalize(example.Sentence),
- Translations = [.. example.Translations.Select(NormalizeTranslation)],
- Reference = StringNormalizer.Normalize(example.Reference)
- };
+ var copy = example.Copy();
+ copy.Sentence = StringNormalizer.Normalize(example.Sentence);
+ copy.Translations = [.. example.Translations.Select(NormalizeTranslation)];
+ copy.Reference = StringNormalizer.Normalize(example.Reference);
+ return copy;
}
private static Translation NormalizeTranslation(Translation translation)
{
- return new Translation
- {
- Id = translation.Id,
- Text = StringNormalizer.Normalize(translation.Text)
- };
+ var copy = translation.Copy();
+ copy.Text = StringNormalizer.Normalize(translation.Text);
+ return copy;
}
#endregion