From d33d746b8641b192cda367baa3eba3edecd6681d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 20:51:58 +0000 Subject: [PATCH 01/28] Seed canonical morph-types into CRDT projects - Add CanonicalMorphTypes with all 19 morph-type definitions (GUIDs from LibLCM) - Seed morph-types for new projects via PreDefinedData.PredefinedMorphTypes - Seed morph-types for existing projects in MigrateDb (before FTS refresh) - Add EF migration to clear FTS table so headwords are rebuilt with morph tokens - Patch legacy snapshots (empty MorphTypes) in sync layer to prevent duplicates - Add tests: seeding, Sena3 verification, sync with legacy snapshots - Add v3 to RegressionVersion enum (v3.sql dump to be generated) https://claude.ai/code/session_01WDKE2vXP4gjMWjfn4cmL4p --- .../FwLiteProjectSync.Tests/Sena3SyncTests.cs | 22 + .../FwLiteProjectSync.Tests/SyncTests.cs | 28 + .../CrdtFwdataProjectSyncService.cs | 12 + .../Data/RegressionTestHelper.cs | 3 +- .../LcmCrdt.Tests/MorphTypeSeedingTests.cs | 140 ++++ backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 1 + .../FwLite/LcmCrdt/CurrentProjectService.cs | 12 + ...nerateSearchTableForMorphTypes.Designer.cs | 785 ++++++++++++++++++ ...0000_RegenerateSearchTableForMorphTypes.cs | 23 + .../FwLite/LcmCrdt/Objects/PreDefinedData.cs | 8 + .../MiniLcm/Models/CanonicalMorphTypes.cs | 174 ++++ 11 files changed, 1207 insertions(+), 1 deletion(-) create mode 100644 backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs create mode 100644 backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.Designer.cs create mode 100644 backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.cs create mode 100644 backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index 1b6a71f1ea..ad1233b6ef 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -96,6 +96,28 @@ private async Task WorkaroundMissingWritingSystems() } + [Fact] + [Trait("Category", "Integration")] + public async Task CanonicalMorphTypes_MatchFwDataMorphTypes() + { + var fwDataMorphTypes = await _fwDataApi.GetMorphTypes().ToArrayAsync(); + fwDataMorphTypes.Should().NotBeEmpty("Sena 3 should have morph types"); + + foreach (var fwMorphType in fwDataMorphTypes) + { + if (fwMorphType.Kind == MorphTypeKind.Unknown || fwMorphType.Kind == MorphTypeKind.Other) + continue; + + CanonicalMorphTypes.All.Should().ContainKey(fwMorphType.Kind, + $"canonical morph types should include {fwMorphType.Kind}"); + var canonical = CanonicalMorphTypes.All[fwMorphType.Kind]; + canonical.Id.Should().Be(fwMorphType.Id, $"GUID for {fwMorphType.Kind} should match FwData"); + canonical.Prefix.Should().Be(fwMorphType.Prefix, $"Prefix for {fwMorphType.Kind} should match FwData"); + canonical.Postfix.Should().Be(fwMorphType.Postfix, $"Postfix for {fwMorphType.Kind} should match FwData"); + canonical.SecondaryOrder.Should().Be(fwMorphType.SecondaryOrder, $"SecondaryOrder for {fwMorphType.Kind} should match FwData"); + } + } + [Fact] [Trait("Category", "Integration")] public async Task DryRunImport_MakesNoChanges() diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs index b37237d59f..db43e941d1 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs @@ -678,4 +678,32 @@ public async Task CanCreateAComplexFormTypeAndSyncsIt() _fixture.FwDataApi.GetComplexFormTypes().ToBlockingEnumerable().Should().ContainEquivalentOf(complexFormEntry); } + + [Fact] + [Trait("Category", "Integration")] + public async Task SyncWithLegacySnapshot_EmptyMorphTypes_DoesNotDuplicate() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + + // First sync: import so both sides have data + await _syncService.Import(crdtApi, fwdataApi); + var snapshot = await _fixture.RegenerateAndGetSnapshot(); + + // Simulate a legacy snapshot by clearing MorphTypes + var legacySnapshot = snapshot with { MorphTypes = [] }; + + // The CRDT should already have morph types (from seeding in MigrateDb). + // Syncing with a legacy snapshot should patch the snapshot and not duplicate morph types. + var syncResult = await _syncService.Sync(crdtApi, fwdataApi, legacySnapshot); + + // Verify no duplicates + var crdtMorphTypes = await crdtApi.GetMorphTypes().ToArrayAsync(); + crdtMorphTypes.Should().OnlyHaveUniqueItems(mt => mt.Kind); + crdtMorphTypes.Should().NotBeEmpty(); + + // Verify no morph-type changes were needed (they were patched from CRDT) + syncResult.CrdtChanges.Should().Be(0); + syncResult.FwdataChanges.Should().Be(0); + } } diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index ed729a7045..5bf4fd967a 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -76,6 +76,18 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc { // Repair any missing translation IDs before doing the full sync, so the sync doesn't have to deal with them var syncedIdCount = await CrdtRepairs.SyncMissingTranslationIds(projectSnapshot.Entries, fwdata, crdt, dryRun); + + // Patch legacy snapshots that were created before morph-type support. + // After seeding, the CRDT has morph-types but the snapshot still has []. + // Without this patch, the diff would see all morph-types as "new" and try to re-add them. + if (projectSnapshot.MorphTypes.Length == 0) + { + var currentCrdtMorphTypes = await crdt.GetMorphTypes().ToArrayAsync(); + if (currentCrdtMorphTypes.Length > 0) + { + projectSnapshot = projectSnapshot with { MorphTypes = currentCrdtMorphTypes }; + } + } } var syncResult = projectSnapshot is null diff --git a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs index 412b433c9d..ff40d0c591 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs @@ -73,6 +73,7 @@ private static string GetFilePath(string name, [CallerFilePath] string sourceFil public enum RegressionVersion { v1, - v2 + v2, + v3 } } diff --git a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs new file mode 100644 index 0000000000..e5e25d8fae --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs @@ -0,0 +1,140 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MiniLcm.Models; +using static LcmCrdt.CrdtProjectsService; + +namespace LcmCrdt.Tests; + +public class MorphTypeSeedingTests +{ + [Fact] + public async Task NewProjectWithSeedData_HasAllCanonicalMorphTypes() + { + var sqliteFile = "MorphTypeSeed_NewProject.sqlite"; + if (File.Exists(sqliteFile)) File.Delete(sqliteFile); + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Services.AddTestLcmCrdtClient(); + using var host = builder.Build(); + await using var scope = host.Services.CreateAsyncScope(); + + var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); + var crdtProject = await crdtProjectsService.CreateProject(new( + Name: "MorphTypeSeedTest", + Code: "morph-type-seed-test", + Path: "", + SeedNewProjectData: true)); + + var api = (CrdtMiniLcmApi)await scope.ServiceProvider.OpenCrdtProject(crdtProject); + var morphTypes = await api.GetMorphTypes().ToArrayAsync(); + + morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count); + foreach (var canonical in CanonicalMorphTypes.All.Values) + { + var mt = morphTypes.Should().ContainSingle(m => m.Kind == canonical.Kind).Subject; + mt.Id.Should().Be(canonical.Id); + mt.Name["en"].Should().Be(canonical.Name["en"]); + mt.Abbreviation["en"].Should().Be(canonical.Abbreviation["en"]); + mt.Prefix.Should().Be(canonical.Prefix); + mt.Postfix.Should().Be(canonical.Postfix); + mt.SecondaryOrder.Should().Be(canonical.SecondaryOrder); + } + + await using var dbContext = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + await dbContext.Database.EnsureDeletedAsync(); + } + + [Fact] + public async Task ExistingProjectWithoutMorphTypes_GetsMorphTypesOnOpen() + { + var sqliteFile = "MorphTypeSeed_ExistingProject.sqlite"; + if (File.Exists(sqliteFile)) File.Delete(sqliteFile); + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Services.AddTestLcmCrdtClient(); + using var host = builder.Build(); + await using var scope = host.Services.CreateAsyncScope(); + + var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); + // Create project WITHOUT seeding + var crdtProject = await crdtProjectsService.CreateProject(new( + Name: "MorphTypeSeedExisting", + Code: "morph-type-seed-existing", + Path: "", + SeedNewProjectData: false)); + + // Opening the project triggers MigrateDb, which seeds morph types if missing + var api = (CrdtMiniLcmApi)await scope.ServiceProvider.OpenCrdtProject(crdtProject); + var morphTypes = await api.GetMorphTypes().ToArrayAsync(); + + morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count); + + await using var dbContext = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + await dbContext.Database.EnsureDeletedAsync(); + } + + [Fact] + public async Task SeedingIsIdempotent_OpeningProjectTwiceDoesNotDuplicate() + { + var sqliteFile = "MorphTypeSeed_Idempotent.sqlite"; + if (File.Exists(sqliteFile)) File.Delete(sqliteFile); + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Services.AddTestLcmCrdtClient(); + using var host = builder.Build(); + + // First open: seed morph types + { + await using var scope = host.Services.CreateAsyncScope(); + var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); + var crdtProject = await crdtProjectsService.CreateProject(new( + Name: "MorphTypeSeedIdempotent", + Code: "morph-type-seed-idempotent", + Path: "", + SeedNewProjectData: true)); + await scope.ServiceProvider.OpenCrdtProject(crdtProject); + } + + // Second open: MigrateDb should detect existing morph types and skip seeding + // Note: MigrationTasks is static, so we need to clear it to re-trigger MigrateDb. + // In production, this doesn't happen (each process lifetime runs once). + // Instead, we verify by count that the seeding itself is duplicate-safe. + { + await using var scope = host.Services.CreateAsyncScope(); + var api = scope.ServiceProvider.GetRequiredService(); + var morphTypes = await api.GetMorphTypes().ToArrayAsync(); + morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count, + "morph types should not be duplicated"); + } + + await using var cleanupScope = host.Services.CreateAsyncScope(); + await using var dbContext = await cleanupScope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + await dbContext.Database.EnsureDeletedAsync(); + } + + [Fact] + public void CanonicalMorphTypes_CoverAllKindsExceptUnknown() + { + var allKinds = Enum.GetValues() + .Where(k => k != MorphTypeKind.Unknown && k != MorphTypeKind.Other) + .ToHashSet(); + + CanonicalMorphTypes.All.Keys.Should().BeEquivalentTo(allKinds); + } + + [Fact] + public void CanonicalMorphTypes_HaveUniqueIds() + { + var ids = CanonicalMorphTypes.All.Values.Select(m => m.Id).ToList(); + ids.Should().OnlyHaveUniqueItems(); + } + + [Fact] + public void CanonicalMorphTypes_HaveRequiredFields() + { + foreach (var mt in CanonicalMorphTypes.All.Values) + { + mt.Id.Should().NotBe(Guid.Empty, $"MorphType {mt.Kind} should have a non-empty Id"); + mt.Name["en"].Should().NotBeNullOrWhiteSpace($"MorphType {mt.Kind} should have an English name"); + mt.Abbreviation["en"].Should().NotBeNullOrWhiteSpace($"MorphType {mt.Kind} should have an English abbreviation"); + } + } +} diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 457fe2f12f..fcce05c721 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -245,6 +245,7 @@ internal static async Task SeedSystemData(DataModel dataModel, Guid clientId) await PreDefinedData.PredefinedPartsOfSpeech(dataModel, clientId); await PreDefinedData.PredefinedSemanticDomains(dataModel, clientId); await PreDefinedData.PredefinedCustomViews(dataModel, clientId); + await PreDefinedData.PredefinedMorphTypes(dataModel, clientId); } [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")] diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index a31a6f7ed2..5096895afd 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -1,10 +1,12 @@ using System.Collections.Concurrent; using LcmCrdt.FullTextSearch; +using LcmCrdt.Objects; using LcmCrdt.Project; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SIL.Harmony; namespace LcmCrdt; @@ -105,6 +107,16 @@ async Task Execute() { await using var dbContext = await DbContextFactory.CreateDbContextAsync(); await dbContext.Database.MigrateAsync(); + + // Seed morph-types if missing (for existing projects created before morph-type support). + // Must happen BEFORE FTS regeneration so headwords include morph-type tokens. + if (!await dbContext.MorphTypes.AnyAsync()) + { + var dataModel = services.GetRequiredService(); + var projectData = await dbContext.ProjectData.AsNoTracking().FirstAsync(); + await PreDefinedData.PredefinedMorphTypes(dataModel, projectData.ClientId); + } + if (EntrySearchServiceFactory is not null) { await using var ess = EntrySearchServiceFactory.CreateSearchService(dbContext); diff --git a/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.Designer.cs b/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.Designer.cs new file mode 100644 index 0000000000..d67a97f6e0 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.Designer.cs @@ -0,0 +1,785 @@ +// +using System; +using System.Collections.Generic; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + [DbContext(typeof(LcmCrdtDbContext))] + [Migration("20260318120000_RegenerateSearchTableForMorphTypes")] + partial class RegenerateSearchTableForMorphTypes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Headword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EntrySearchRecord", null, t => + { + t.ExcludeFromMigrations(); + }); + }); + + modelBuilder.Entity("LcmCrdt.ProjectData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FwProjectId") + .HasColumnType("TEXT"); + + b.Property("LastUserId") + .HasColumnType("TEXT"); + + b.Property("LastUserName") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginDomain") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + + b.HasKey("Id"); + + b.ToTable("ProjectData"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ComplexFormEntryId") + .HasColumnType("TEXT"); + + b.Property("ComplexFormHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentEntryId") + .HasColumnType("TEXT"); + + b.Property("ComponentHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentSenseId") + .HasColumnType("TEXT") + .HasColumnName("ComponentSenseId"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentEntryId"); + + b.HasIndex("ComponentSenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId") + .IsUnique() + .HasFilter("ComponentSenseId IS NULL"); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId", "ComponentSenseId") + .IsUnique() + .HasFilter("ComponentSenseId IS NOT NULL"); + + b.ToTable("ComplexFormComponents", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ComplexFormType"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ComplexFormTypes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LiteralMeaning") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MorphType") + .HasColumnType("INTEGER"); + + b.Property("Note") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishIn") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Entry"); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("Reference") + .HasColumnType("jsonb"); + + b.Property("SenseId") + .HasColumnType("TEXT"); + + b.Property("Sentence") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Translations") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ExampleSentence"); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Postfix") + .HasColumnType("TEXT"); + + b.Property("Prefix") + .HasColumnType("TEXT"); + + b.Property("SecondaryOrder") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Kind") + .IsUnique(); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("MorphType"); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Publication"); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("SemanticDomain"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("PartOfSpeechId") + .HasColumnType("TEXT"); + + b.Property("SemanticDomains") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntryId"); + + b.HasIndex("PartOfSpeechId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Sense"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Exemplars") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Font") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WsId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("WsId", "Type") + .IsUnique(); + + b.ToTable("WritingSystem"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty>("HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("INTEGER") + .HasColumnName("Counter"); + + b1.Property("DateTime") + .HasColumnType("TEXT") + .HasColumnName("DateTime"); + }); + + b.HasKey("Id"); + + b.ToTable("Commits", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Change") + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.HasKey("CommitId", "Index"); + + b.ToTable("ChangeEntities", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Entity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityIsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRoot") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("References") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityId"); + + b.HasIndex("CommitId", "EntityId") + .IsUnique(); + + b.ToTable("Snapshots", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.LocalResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LocalResource"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("RemoteResource"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Components") + .HasForeignKey("ComplexFormEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("ComplexForms") + .HasForeignKey("ComponentEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany() + .HasForeignKey("ComponentSenseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormComponent", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Entry", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany("ExampleSentences") + .HasForeignKey("SenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ExampleSentence", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.MorphType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.PartOfSpeech", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Publication", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.SemanticDomain", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Senses") + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.PartOfSpeech", "PartOfSpeech") + .WithMany() + .HasForeignKey("PartOfSpeechId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Sense", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.WritingSystem", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.HasOne("SIL.Harmony.Commit", null) + .WithMany("ChangeEntities") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.HasOne("SIL.Harmony.Commit", "Commit") + .WithMany("Snapshots") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Commit"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("SIL.Harmony.Resource.RemoteResource", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Navigation("ComplexForms"); + + b.Navigation("Components"); + + b.Navigation("Senses"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Navigation("ExampleSentences"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Navigation("ChangeEntities"); + + b.Navigation("Snapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.cs b/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.cs new file mode 100644 index 0000000000..a6f9e1d491 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + /// + public partial class RegenerateSearchTableForMorphTypes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Force FTS rebuild so headwords include morph-type prefix/postfix tokens + migrationBuilder.Sql("DELETE FROM EntrySearchRecord;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // FTS table will be lazily regenerated + } + } +} diff --git a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs index 37ab3d0db5..bb83842b83 100644 --- a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs +++ b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs @@ -1,4 +1,5 @@ using LcmCrdt.Changes; +using MiniLcm.Models; using SIL.Harmony; namespace LcmCrdt.Objects; @@ -73,4 +74,11 @@ await dataModel.AddChanges(clientId, ], new Guid("b2c3d4e5-f6a7-8901-bcde-f12345678901")); } + + internal static async Task PredefinedMorphTypes(DataModel dataModel, Guid clientId) + { + await dataModel.AddChanges(clientId, + CanonicalMorphTypes.All.Values.Select(mt => new CreateMorphTypeChange(mt)).ToArray(), + new Guid("a7b2c3d4-e5f6-4a8b-9c0d-1e2f3a4b5c6d")); + } } diff --git a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs new file mode 100644 index 0000000000..f5702a28cc --- /dev/null +++ b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs @@ -0,0 +1,174 @@ +using System.Collections.Frozen; + +namespace MiniLcm.Models; + +/// +/// Canonical morph-type definitions matching FieldWorks/LibLCM MoMorphTypeTags. +/// GUIDs match SIL.LCModel constants (kguidMorph*). Data verified against Sena 3 FwData project. +/// +public static class CanonicalMorphTypes +{ + public static readonly FrozenDictionary All = CreateAll().ToFrozenDictionary(m => m.Kind); + + private static MorphType[] CreateAll() => + [ + new() + { + Id = new Guid("d7f713e4-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.BoundRoot, + Name = new MultiString { { "en", "bound root" } }, + Abbreviation = new MultiString { { "en", "bd root" } }, + Prefix = "*", + SecondaryOrder = 10, + }, + new() + { + Id = new Guid("d7f713e7-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.BoundStem, + Name = new MultiString { { "en", "bound stem" } }, + Abbreviation = new MultiString { { "en", "bd stem" } }, + Prefix = "*", + SecondaryOrder = 10, + }, + new() + { + Id = new Guid("d7f713df-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Circumfix, + Name = new MultiString { { "en", "circumfix" } }, + Abbreviation = new MultiString { { "en", "cfx" } }, + }, + new() + { + Id = new Guid("c2d140e5-7ca9-41f4-a69a-22fc7049dd2c"), + Kind = MorphTypeKind.Clitic, + Name = new MultiString { { "en", "clitic" } }, + Abbreviation = new MultiString { { "en", "clit" } }, + }, + new() + { + Id = new Guid("d7f713e1-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Enclitic, + Name = new MultiString { { "en", "enclitic" } }, + Abbreviation = new MultiString { { "en", "enclit" } }, + Prefix = "=", + SecondaryOrder = 80, + }, + new() + { + Id = new Guid("d7f713da-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Infix, + Name = new MultiString { { "en", "infix" } }, + Abbreviation = new MultiString { { "en", "ifx" } }, + Prefix = "-", + Postfix = "-", + SecondaryOrder = 40, + }, + new() + { + Id = new Guid("56db04bf-3d58-44cc-b292-4c8aa68538f4"), + Kind = MorphTypeKind.Particle, + Name = new MultiString { { "en", "particle" } }, + Abbreviation = new MultiString { { "en", "part" } }, + }, + new() + { + Id = new Guid("d7f713db-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Prefix, + Name = new MultiString { { "en", "prefix" } }, + Abbreviation = new MultiString { { "en", "pfx" } }, + Postfix = "-", + SecondaryOrder = 20, + }, + new() + { + Id = new Guid("d7f713e2-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Proclitic, + Name = new MultiString { { "en", "proclitic" } }, + Abbreviation = new MultiString { { "en", "proclit" } }, + Postfix = "=", + SecondaryOrder = 30, + }, + new() + { + Id = new Guid("d7f713e5-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Root, + Name = new MultiString { { "en", "root" } }, + Abbreviation = new MultiString { { "en", "ubd root" } }, + }, + new() + { + Id = new Guid("d7f713dc-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Simulfix, + Name = new MultiString { { "en", "simulfix" } }, + Abbreviation = new MultiString { { "en", "smfx" } }, + Prefix = "=", + Postfix = "=", + SecondaryOrder = 60, + }, + new() + { + Id = new Guid("d7f713e8-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Stem, + Name = new MultiString { { "en", "stem" } }, + Abbreviation = new MultiString { { "en", "ubd stem" } }, + }, + new() + { + Id = new Guid("d7f713dd-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Suffix, + Name = new MultiString { { "en", "suffix" } }, + Abbreviation = new MultiString { { "en", "sfx" } }, + Prefix = "-", + SecondaryOrder = 70, + }, + new() + { + Id = new Guid("d7f713de-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Suprafix, + Name = new MultiString { { "en", "suprafix" } }, + Abbreviation = new MultiString { { "en", "spfx" } }, + Prefix = "~", + Postfix = "~", + SecondaryOrder = 50, + }, + new() + { + Id = new Guid("18d9b1c3-b5b6-4c07-b92c-2fe1d2281bd4"), + Kind = MorphTypeKind.InfixingInterfix, + Name = new MultiString { { "en", "infixing interfix" } }, + Abbreviation = new MultiString { { "en", "ifxnfx" } }, + Prefix = "-", + Postfix = "-", + }, + new() + { + Id = new Guid("af6537b0-7175-4387-ba6a-36547d37fb13"), + Kind = MorphTypeKind.PrefixingInterfix, + Name = new MultiString { { "en", "prefixing interfix" } }, + Abbreviation = new MultiString { { "en", "pfxnfx" } }, + Postfix = "-", + }, + new() + { + Id = new Guid("3433683d-08a9-4bae-ae53-2a7798f64068"), + Kind = MorphTypeKind.SuffixingInterfix, + Name = new MultiString { { "en", "suffixing interfix" } }, + Abbreviation = new MultiString { { "en", "sfxnfx" } }, + Prefix = "-", + }, + new() + { + Id = new Guid("a23b6faa-1052-4f4d-984b-4b338bdaf95f"), + Kind = MorphTypeKind.Phrase, + Name = new MultiString { { "en", "phrase" } }, + Abbreviation = new MultiString { { "en", "phr" } }, + }, + new() + { + Id = new Guid("0cc8c35a-cee9-434d-be58-5d29130fba5b"), + Kind = MorphTypeKind.DiscontiguousPhrase, + Name = new MultiString { { "en", "discontiguous phrase" } }, + Abbreviation = new MultiString { { "en", "dis phr" } }, + }, + ]; +} From 79b95da6a3a183527d5497c023b3f9cd5c700c80 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 08:51:05 +0000 Subject: [PATCH 02/28] Make SecondaryOrder explicit on all morph types, add reverse coverage check - Add SecondaryOrder = 0 to all morph types that were relying on the default - Add assertion that all canonical morph types exist in FwData (not just the reverse) https://claude.ai/code/session_01WDKE2vXP4gjMWjfn4cmL4p --- .../FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs | 9 +++++++++ backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index ad1233b6ef..c09fa12032 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -103,6 +103,7 @@ public async Task CanonicalMorphTypes_MatchFwDataMorphTypes() var fwDataMorphTypes = await _fwDataApi.GetMorphTypes().ToArrayAsync(); fwDataMorphTypes.Should().NotBeEmpty("Sena 3 should have morph types"); + // Verify every FwData morph type has a matching canonical entry foreach (var fwMorphType in fwDataMorphTypes) { if (fwMorphType.Kind == MorphTypeKind.Unknown || fwMorphType.Kind == MorphTypeKind.Other) @@ -116,6 +117,14 @@ public async Task CanonicalMorphTypes_MatchFwDataMorphTypes() canonical.Postfix.Should().Be(fwMorphType.Postfix, $"Postfix for {fwMorphType.Kind} should match FwData"); canonical.SecondaryOrder.Should().Be(fwMorphType.SecondaryOrder, $"SecondaryOrder for {fwMorphType.Kind} should match FwData"); } + + // Verify every canonical morph type exists in FwData (no extras we shouldn't have) + var fwDataKinds = fwDataMorphTypes + .Where(m => m.Kind != MorphTypeKind.Unknown && m.Kind != MorphTypeKind.Other) + .Select(m => m.Kind) + .ToHashSet(); + CanonicalMorphTypes.All.Keys.Should().BeSubsetOf(fwDataKinds, + "every canonical morph type should exist in the Sena 3 FwData project"); } [Fact] diff --git a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs index f5702a28cc..a14964452a 100644 --- a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs +++ b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs @@ -36,6 +36,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Circumfix, Name = new MultiString { { "en", "circumfix" } }, Abbreviation = new MultiString { { "en", "cfx" } }, + SecondaryOrder = 0, }, new() { @@ -43,6 +44,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Clitic, Name = new MultiString { { "en", "clitic" } }, Abbreviation = new MultiString { { "en", "clit" } }, + SecondaryOrder = 0, }, new() { @@ -69,6 +71,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Particle, Name = new MultiString { { "en", "particle" } }, Abbreviation = new MultiString { { "en", "part" } }, + SecondaryOrder = 0, }, new() { @@ -94,6 +97,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Root, Name = new MultiString { { "en", "root" } }, Abbreviation = new MultiString { { "en", "ubd root" } }, + SecondaryOrder = 0, }, new() { @@ -111,6 +115,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Stem, Name = new MultiString { { "en", "stem" } }, Abbreviation = new MultiString { { "en", "ubd stem" } }, + SecondaryOrder = 0, }, new() { @@ -139,6 +144,7 @@ private static MorphType[] CreateAll() => Abbreviation = new MultiString { { "en", "ifxnfx" } }, Prefix = "-", Postfix = "-", + SecondaryOrder = 0, }, new() { @@ -147,6 +153,7 @@ private static MorphType[] CreateAll() => Name = new MultiString { { "en", "prefixing interfix" } }, Abbreviation = new MultiString { { "en", "pfxnfx" } }, Postfix = "-", + SecondaryOrder = 0, }, new() { @@ -155,6 +162,7 @@ private static MorphType[] CreateAll() => Name = new MultiString { { "en", "suffixing interfix" } }, Abbreviation = new MultiString { { "en", "sfxnfx" } }, Prefix = "-", + SecondaryOrder = 0, }, new() { @@ -162,6 +170,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Phrase, Name = new MultiString { { "en", "phrase" } }, Abbreviation = new MultiString { { "en", "phr" } }, + SecondaryOrder = 0, }, new() { @@ -169,6 +178,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.DiscontiguousPhrase, Name = new MultiString { { "en", "discontiguous phrase" } }, Abbreviation = new MultiString { { "en", "dis phr" } }, + SecondaryOrder = 0, }, ]; } From f296a6cdcbae748e9eb95c02f5f7299151cf571a Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 24 Mar 2026 15:32:17 +0100 Subject: [PATCH 03/28] Stop creating morph-types in tests. They're now prepopulated --- .../MiniLcmTests/SortingTests.cs | 9 ---- .../MiniLcm.Tests/QueryEntryTestsBase.cs | 40 --------------- .../FwLite/MiniLcm.Tests/SortingTestsBase.cs | 49 ------------------- 3 files changed, 98 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs index e32b8f355e..1f0089f470 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs @@ -24,15 +24,6 @@ public override async Task DisposeAsync() [InlineData("a", SortField.SearchRelevance)] // non-FTS public async Task SecondaryOrder_DefaultsToStem(string query, SortField sortField) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Suffix, Name = { ["en"] = "Suffix" }, Postfix = "-", SecondaryOrder = 6 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - Entry[] expected = [ new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Unknown }, // SecondaryOrder defaults to Stem = 1 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.BoundStem }, // SecondaryOrder = 2 diff --git a/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs index 2481fae96b..c8f9269540 100644 --- a/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs @@ -523,14 +523,6 @@ public async Task PunctuationWorks(string searchTerm, string word) public async Task SearchEntries_MatchesLexeme(string searchTerm) { var prefixQuery = $"{searchTerm}-"; - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Prefix, - Name = { ["en"] = "Prefix" }, - Postfix = "-", - SecondaryOrder = 3 - }); var lexemeOnlyMatchEntry = await Api.CreateEntry(new Entry { LexemeForm = { ["en"] = "mango" }, @@ -554,14 +546,6 @@ await Api.CreateMorphType(new MorphType public async Task SearchEntries_CitationFormOverridesMorphTokens(string searchTerm) { var prefixQuery = $"{searchTerm}-"; - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Prefix, - Name = { ["en"] = "Prefix" }, - Postfix = "-", - SecondaryOrder = 3 - }); var entryWithOverriddenMorphToken = await Api.CreateEntry(new Entry { LexemeForm = { ["en"] = "mango" }, @@ -578,14 +562,6 @@ await Api.CreateMorphType(new MorphType [InlineData("o-")] // non-FTS public async Task MorphTokenSearch_FindsPrefixEntry(string searchTerm) { - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Prefix, - Name = { ["en"] = "Prefix" }, - Postfix = "-", - SecondaryOrder = 3 - }); var id = Guid.NewGuid(); await Api.CreateEntry(new Entry { Id = id, LexemeForm = { ["en"] = "mango" }, MorphType = MorphTypeKind.Prefix }); @@ -598,14 +574,6 @@ await Api.CreateMorphType(new MorphType [InlineData("-m")] // non-FTS public async Task MorphTokenSearch_FindsSuffixEntry(string searchTerm) { - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Suffix, - Name = { ["en"] = "Suffix" }, - Prefix = "-", - SecondaryOrder = 6 - }); var id = Guid.NewGuid(); await Api.CreateEntry(new Entry { Id = id, LexemeForm = { ["en"] = "mango" }, MorphType = MorphTypeKind.Suffix }); @@ -616,14 +584,6 @@ await Api.CreateMorphType(new MorphType [Fact] public async Task MorphTokenSearch_DoesNotMatchWithoutToken() { - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Prefix, - Name = { ["en"] = "Prefix" }, - Postfix = "-", - SecondaryOrder = 3 - }); await Api.CreateEntry(new Entry { LexemeForm = { ["en"] = "mango" }, MorphType = MorphTypeKind.Root }); // Searching for "-mango" should NOT match a Root entry (no morph tokens) diff --git a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs index a815324133..e4d19fbff2 100644 --- a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs @@ -62,15 +62,6 @@ await Api.GetEntries(new QueryOptions(new SortOptions(SortField.Headword, wsId)) [InlineData("a", SortField.SearchRelevance)] // non-FTS public async Task MorphTokens_DoNotAffectSortOrder(string query, SortField sortField) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Prefix, Name = { ["en"] = "Prefix" }, Prefix = "-", SecondaryOrder = 3 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Suffix, Name = { ["en"] = "Suffix" }, Postfix = "-", SecondaryOrder = 6 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - // All three entries have LexemeForm "aaaa". Their headwords are: // Root: "aaaa" (no tokens) // Prefix: "-aaaa" (leading token "-") @@ -100,16 +91,6 @@ public async Task MorphTokens_DoNotAffectSortOrder(string query, SortField sortF [InlineData("a")] // non-FTS rank public async Task SecondaryOrder_Relevance_LexemeForm(string searchTerm) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundRoot, Name = { ["en"] = "BoundRoot" }, SecondaryOrder = 2 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - static Entry[] CreateSortedEntrySet(string headword) { return [ @@ -168,16 +149,6 @@ static Entry[] CreateSortedEntrySet(string headword) [InlineData("a")] // non-FTS rank public async Task SecondaryOrder_Relevance_CitationForm(string searchTerm) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundRoot, Name = { ["en"] = "BoundRoot" }, SecondaryOrder = 2 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - static Entry[] CreateSortedEntrySet(string headword) { return [ @@ -236,16 +207,6 @@ static Entry[] CreateSortedEntrySet(string headword) [InlineData("b")] // non-FTS rank public async Task SecondaryOrder_Headword_LexemeForm(string searchTerm) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundRoot, Name = { ["en"] = "BoundRoot" }, SecondaryOrder = 2 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - Entry[] expected = [ // Root/Stem - SecondaryOrder: 1 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "abaaa" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, @@ -281,16 +242,6 @@ public async Task SecondaryOrder_Headword_LexemeForm(string searchTerm) [InlineData("b")] // non-FTS rank public async Task SecondaryOrder_Headword_CitationForm(string searchTerm) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundRoot, Name = { ["en"] = "BoundRoot" }, SecondaryOrder = 2 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - Entry[] expected = [ // Root/Stem - SecondaryOrder: 1 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "abaaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, From a23730e1d4cf1247e5a708571dbdfe17aaefc419 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 24 Mar 2026 15:56:31 +0100 Subject: [PATCH 04/28] Remove references to delete MorphTypeKind.Other --- backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs | 4 ++-- backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index c09fa12032..1c2fb60753 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -106,7 +106,7 @@ public async Task CanonicalMorphTypes_MatchFwDataMorphTypes() // Verify every FwData morph type has a matching canonical entry foreach (var fwMorphType in fwDataMorphTypes) { - if (fwMorphType.Kind == MorphTypeKind.Unknown || fwMorphType.Kind == MorphTypeKind.Other) + if (fwMorphType.Kind == MorphTypeKind.Unknown) continue; CanonicalMorphTypes.All.Should().ContainKey(fwMorphType.Kind, @@ -120,7 +120,7 @@ public async Task CanonicalMorphTypes_MatchFwDataMorphTypes() // Verify every canonical morph type exists in FwData (no extras we shouldn't have) var fwDataKinds = fwDataMorphTypes - .Where(m => m.Kind != MorphTypeKind.Unknown && m.Kind != MorphTypeKind.Other) + .Where(m => m.Kind != MorphTypeKind.Unknown) .Select(m => m.Kind) .ToHashSet(); CanonicalMorphTypes.All.Keys.Should().BeSubsetOf(fwDataKinds, diff --git a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs index e5e25d8fae..225b93b2d1 100644 --- a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs @@ -114,7 +114,7 @@ public async Task SeedingIsIdempotent_OpeningProjectTwiceDoesNotDuplicate() public void CanonicalMorphTypes_CoverAllKindsExceptUnknown() { var allKinds = Enum.GetValues() - .Where(k => k != MorphTypeKind.Unknown && k != MorphTypeKind.Other) + .Where(k => k != MorphTypeKind.Unknown) .ToHashSet(); CanonicalMorphTypes.All.Keys.Should().BeEquivalentTo(allKinds); From c24dd8fe8cea108cccd4f9f54e8b34cbe9ae5978 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 24 Mar 2026 15:56:57 +0100 Subject: [PATCH 05/28] Stop printing verify diff content. It's too much. --- .../FwLiteProjectSync.Tests/FluentAssertGlobalConfig.cs | 7 +++++++ backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs | 1 + 2 files changed, 8 insertions(+) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/FluentAssertGlobalConfig.cs b/backend/FwLite/FwLiteProjectSync.Tests/FluentAssertGlobalConfig.cs index fa6983d27d..eb883aaa68 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/FluentAssertGlobalConfig.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/FluentAssertGlobalConfig.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using FluentAssertions.Extensibility; using FwLiteProjectSync.Tests; @@ -7,6 +8,12 @@ namespace FwLiteProjectSync.Tests; public static class FluentAssertGlobalConfig { + [ModuleInitializer] + internal static void InitVerify() + { + VerifierSettings.OmitContentFromException(); + } + public static void Initialize() { MiniLcm.Tests.FluentAssertGlobalConfig.Initialize(); diff --git a/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs index bbe2e496e1..b8c985e6c4 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs @@ -22,6 +22,7 @@ public class MigrationTests : IAsyncLifetime internal static void Init() { VerifySystemJson.Initialize(); + VerifierSettings.OmitContentFromException(); } public Task InitializeAsync() From 016adb65526d235bb9587c2a8585be5b7584260d Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 24 Mar 2026 16:39:08 +0100 Subject: [PATCH 06/28] Fix test --- backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs index 3c70a5184e..505a20b999 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs @@ -12,9 +12,9 @@ public FilteringTests() _entries = [ new Entry { LexemeForm = { { "en", "123" } }, }, - new Entry { LexemeForm = { { "en", "456" } }, } + new Entry { LexemeForm = { { "en", "456" } }, }, ]; - _morphTypes = new MorphType[] { new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 } }.AsQueryable(); + _morphTypes = CanonicalMorphTypes.All.Values.ToArray().AsQueryable(); } [Theory] From 0e09fb932bfb65089e7858c7747c9bb475e734d1 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 31 Mar 2026 15:21:23 +0700 Subject: [PATCH 07/28] Seed morph types before API testing Morph types are now required for correct sorting order, so we don't have an option like _seedWs for not seeding them. --- backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index 0adf9acb29..da19839223 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using LcmCrdt.MediaServer; +using LcmCrdt.Objects; using Meziantou.Extensions.Logging.Xunit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -71,9 +72,11 @@ public async Task InitializeAsync(string projectName) _crdtDbContext = await _services.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); await _crdtDbContext.Database.OpenConnectionAsync(); //can't use ProjectsService.CreateProject because it opens and closes the db context, this would wipe out the in memory db. + var projectData = new ProjectData("Sena 3", projectName, Guid.NewGuid(), null, Guid.NewGuid()); await CrdtProjectsService.InitProjectDb(_crdtDbContext, - new ProjectData("Sena 3", projectName, Guid.NewGuid(), null, Guid.NewGuid())); + projectData); await currentProjectService.RefreshProjectData(); + await PreDefinedData.PredefinedMorphTypes(_services.ServiceProvider.GetRequiredService(), projectData.ClientId); if (_seedWs) { await Api.CreateWritingSystem(new WritingSystem() From 6f5d258945849c7d67a908d213c8b63554518b6c Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 31 Mar 2026 15:54:41 +0700 Subject: [PATCH 08/28] ResumableTests should expect real morph types now --- .../Import/ResumableTests.cs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs index 954084d1bc..2a2f074147 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs @@ -1,3 +1,4 @@ +using LcmCrdt.Objects; using LcmCrdt.Tests; using Microsoft.Extensions.Logging.Abstractions; using MiniLcm; @@ -38,18 +39,7 @@ public async Task ImportProject_IsResumable_AcrossRandomFailures() }).ToList(); var expectedPartsOfSpeech = Enumerable.Range(1, 10) .Select(i => new PartOfSpeech { Id = Guid.NewGuid(), Name = { ["en"] = $"pos{i}" } }).ToList(); - var expectedMorphTypes = Enum.GetValues() - .Select(typ => new MorphType() - { - Id = Guid.NewGuid(), - Name = new() { ["en"] = $"Test Morph Type {(int)typ} {typ}" }, - Abbreviation = new() { ["en"] = $"Tst MrphTyp{(int)typ}" }, - Description = new() { { "en", new RichString($"test desc for {typ}") } }, - Prefix = null, - Postfix = null, - Kind = typ, - SecondaryOrder = 0 - }).ToList(); + var expectedMorphTypes = CanonicalMorphTypes.All.Values; var mockFrom = new Mock(); IMiniLcmApi mockTo = new UnreliableApi( @@ -132,7 +122,6 @@ public async Task ImportProject_IsResumable_AcrossRandomFailures() createdEntries.Select(e => e.LexemeForm["en"]).Should().BeEquivalentTo(expectedEntries.Select(e => e.LexemeForm["en"])); createdMorphTypes.Select(e => e.Name["en"]).Should().BeEquivalentTo(expectedMorphTypes.Select(e => e.Name["en"])); createdMorphTypes.Select(e => e.Kind).Should().BeEquivalentTo(expectedMorphTypes.Select(e => e.Kind)); - } From 71371e6ec7395bbe5f9313656c97f138e6e742b5 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 1 Apr 2026 12:12:54 +0700 Subject: [PATCH 09/28] Fix bad LLM-generated test Claude doesn't understand how the IMiniLcmApi works (need to open a project first), and also was looking for the wrong filename in pre-test cleanup attempts so the test couldn't run twice. --- .../LcmCrdt.Tests/MorphTypeSeedingTests.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs index 225b93b2d1..7b23c75118 100644 --- a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs @@ -75,7 +75,8 @@ public async Task ExistingProjectWithoutMorphTypes_GetsMorphTypesOnOpen() [Fact] public async Task SeedingIsIdempotent_OpeningProjectTwiceDoesNotDuplicate() { - var sqliteFile = "MorphTypeSeed_Idempotent.sqlite"; + var code = "morph-type-seed-idempotent"; + var sqliteFile = $"{code}.sqlite"; if (File.Exists(sqliteFile)) File.Delete(sqliteFile); var builder = Host.CreateEmptyApplicationBuilder(null); builder.Services.AddTestLcmCrdtClient(); @@ -87,7 +88,7 @@ public async Task SeedingIsIdempotent_OpeningProjectTwiceDoesNotDuplicate() var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); var crdtProject = await crdtProjectsService.CreateProject(new( Name: "MorphTypeSeedIdempotent", - Code: "morph-type-seed-idempotent", + Code: code, Path: "", SeedNewProjectData: true)); await scope.ServiceProvider.OpenCrdtProject(crdtProject); @@ -99,15 +100,17 @@ public async Task SeedingIsIdempotent_OpeningProjectTwiceDoesNotDuplicate() // Instead, we verify by count that the seeding itself is duplicate-safe. { await using var scope = host.Services.CreateAsyncScope(); - var api = scope.ServiceProvider.GetRequiredService(); + var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); + var crdtProject = crdtProjectsService.GetProject(code); + crdtProject.Should().NotBeNull(); + var api = await crdtProjectsService.OpenProject(crdtProject, scope.ServiceProvider); var morphTypes = await api.GetMorphTypes().ToArrayAsync(); morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count, "morph types should not be duplicated"); - } - await using var cleanupScope = host.Services.CreateAsyncScope(); - await using var dbContext = await cleanupScope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); - await dbContext.Database.EnsureDeletedAsync(); + await using var dbContext = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + await dbContext.Database.EnsureDeletedAsync(); + } } [Fact] From d082d90dcae68d6b570db0c6d05611d342c2c955 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 1 Apr 2026 12:15:00 +0700 Subject: [PATCH 10/28] Fix another wrong pre-cleanup filename Problem is similar to the wrong filename generated by Claude, but this one was human-generated and became wrong when we switched from project names to project codes in the filename for the sqlite file. --- backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs index 269ae2a0b2..997012e4c6 100644 --- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs @@ -51,7 +51,7 @@ public async Task ProjectDbIsDeletedIfCreateFails() [Fact] public async Task OpeningAProjectWorks() { - var sqliteConnectionString = "OpeningAProjectWorks.sqlite"; + var sqliteConnectionString = "opening-a-project-works.sqlite"; if (File.Exists(sqliteConnectionString)) File.Delete(sqliteConnectionString); var builder = Host.CreateEmptyApplicationBuilder(null); builder.Services.AddTestLcmCrdtClient(); From c87dbfb87b091324493c1fa3a4b3c8a43f5a3093 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 1 Apr 2026 13:15:46 +0700 Subject: [PATCH 11/28] Add descriptions to canonical morph types --- .../MiniLcm/Models/CanonicalMorphTypes.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs index a14964452a..8e8df6ccc0 100644 --- a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs +++ b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs @@ -18,6 +18,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.BoundRoot, Name = new MultiString { { "en", "bound root" } }, Abbreviation = new MultiString { { "en", "bd root" } }, + Description = new RichMultiString { { "en", new RichString("A bound root is a root which cannot occur as a separate word apart from any other morpheme.") } }, Prefix = "*", SecondaryOrder = 10, }, @@ -27,6 +28,8 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.BoundStem, Name = new MultiString { { "en", "bound stem" } }, Abbreviation = new MultiString { { "en", "bd stem" } }, + // Do not correct the doubled space in the next line + Description = new RichMultiString { { "en", new RichString("A bound stem is a stem which cannot occur as a separate word apart from any other morpheme.") } }, Prefix = "*", SecondaryOrder = 10, }, @@ -36,6 +39,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Circumfix, Name = new MultiString { { "en", "circumfix" } }, Abbreviation = new MultiString { { "en", "cfx" } }, + Description = new RichMultiString { { "en", new RichString("A circumfix is an affix made up of two separate parts which surround and attach to a root or stem.") } }, SecondaryOrder = 0, }, new() @@ -44,6 +48,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Clitic, Name = new MultiString { { "en", "clitic" } }, Abbreviation = new MultiString { { "en", "clit" } }, + Description = new RichMultiString { { "en", new RichString("A clitic is a morpheme that has syntactic characteristics of a word, but shows evidence of being phonologically bound to another word. Orthographically, it stands alone.") } }, SecondaryOrder = 0, }, new() @@ -52,6 +57,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Enclitic, Name = new MultiString { { "en", "enclitic" } }, Abbreviation = new MultiString { { "en", "enclit" } }, + Description = new RichMultiString { { "en", new RichString("An enclitic is a clitic that is phonologically joined at the end of a preceding word to form a single unit.") } }, Prefix = "=", SecondaryOrder = 80, }, @@ -61,6 +67,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Infix, Name = new MultiString { { "en", "infix" } }, Abbreviation = new MultiString { { "en", "ifx" } }, + Description = new RichMultiString { { "en", new RichString("An infix is an affix that is inserted within a root or stem.") } }, Prefix = "-", Postfix = "-", SecondaryOrder = 40, @@ -71,6 +78,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Particle, Name = new MultiString { { "en", "particle" } }, Abbreviation = new MultiString { { "en", "part" } }, + Description = new RichMultiString { { "en", new RichString("A particle is a word that does not belong to one of the main classes of words, is invariable in form, and typically has grammatical or pragmatic meaning.") } }, SecondaryOrder = 0, }, new() @@ -79,6 +87,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Prefix, Name = new MultiString { { "en", "prefix" } }, Abbreviation = new MultiString { { "en", "pfx" } }, + Description = new RichMultiString { { "en", new RichString("A prefix is an affix that is joined before a root or stem.") } }, Postfix = "-", SecondaryOrder = 20, }, @@ -88,6 +97,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Proclitic, Name = new MultiString { { "en", "proclitic" } }, Abbreviation = new MultiString { { "en", "proclit" } }, + Description = new RichMultiString { { "en", new RichString("A proclitic is a clitic that precedes the word to which it is phonologically joined.") } }, Postfix = "=", SecondaryOrder = 30, }, @@ -97,6 +107,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Root, Name = new MultiString { { "en", "root" } }, Abbreviation = new MultiString { { "en", "ubd root" } }, + Description = new RichMultiString { { "en", new RichString("A root is the portion of a word that (i) is common to a set of derived or inflected forms, if any, when all affixes are removed, (ii) is not further analyzable into meaningful elements, being morphologically simple, and, (iii) carries the principle portion of meaning of the words in which it functions.") } }, SecondaryOrder = 0, }, new() @@ -105,6 +116,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Simulfix, Name = new MultiString { { "en", "simulfix" } }, Abbreviation = new MultiString { { "en", "smfx" } }, + Description = new RichMultiString { { "en", new RichString("A simulfix is a change or replacement of vowels or consonants (usually vowels) which changes the meaning of a word. (Note: the parser does not currently handle simulfixes.)") } }, Prefix = "=", Postfix = "=", SecondaryOrder = 60, @@ -115,6 +127,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Stem, Name = new MultiString { { "en", "stem" } }, Abbreviation = new MultiString { { "en", "ubd stem" } }, + Description = new RichMultiString { { "en", new RichString("A stem is the root or roots of a word, together with any derivational affixes, to which inflectional affixes are added.\" (LinguaLinks Library). A stem \"may consist solely of a single root morpheme (i.e. a 'simple' stem as in 'man'), or of two root morphemes (e.g. a 'compound' stem, as in 'blackbird'), or of a root morpheme plus a derivational affix (i.e. a 'complex' stem, as in 'manly', 'unmanly', 'manliness'). All have in common the notion that it is to the stem that inflectional affixes are attached.\" (Crystal, 1997:362)") } }, SecondaryOrder = 0, }, new() @@ -123,6 +136,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Suffix, Name = new MultiString { { "en", "suffix" } }, Abbreviation = new MultiString { { "en", "sfx" } }, + Description = new RichMultiString { { "en", new RichString("A suffix is an affix that is attached to the end of a root or stem.") } }, Prefix = "-", SecondaryOrder = 70, }, @@ -132,6 +146,8 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Suprafix, Name = new MultiString { { "en", "suprafix" } }, Abbreviation = new MultiString { { "en", "spfx" } }, + // Do not correct the doubled space in the next line + Description = new RichMultiString { { "en", new RichString("A suprafix is a kind of affix in which a suprasegmental is superimposed on one or more syllables of the root or stem, signalling a particular morphosyntactic operation. (Note: the parser does not currently handle suprafixes.)") } }, Prefix = "~", Postfix = "~", SecondaryOrder = 50, @@ -142,6 +158,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.InfixingInterfix, Name = new MultiString { { "en", "infixing interfix" } }, Abbreviation = new MultiString { { "en", "ifxnfx" } }, + Description = new RichMultiString { { "en", new RichString("An infixing interfix is an infix that can occur between two roots or stems.") } }, Prefix = "-", Postfix = "-", SecondaryOrder = 0, @@ -152,6 +169,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.PrefixingInterfix, Name = new MultiString { { "en", "prefixing interfix" } }, Abbreviation = new MultiString { { "en", "pfxnfx" } }, + Description = new RichMultiString { { "en", new RichString("A prefixing interfix is a prefix that can occur between two roots or stems.") } }, Postfix = "-", SecondaryOrder = 0, }, @@ -161,6 +179,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.SuffixingInterfix, Name = new MultiString { { "en", "suffixing interfix" } }, Abbreviation = new MultiString { { "en", "sfxnfx" } }, + Description = new RichMultiString { { "en", new RichString("A suffixing interfix is an suffix that can occur between two roots or stems.") } }, Prefix = "-", SecondaryOrder = 0, }, @@ -170,6 +189,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.Phrase, Name = new MultiString { { "en", "phrase" } }, Abbreviation = new MultiString { { "en", "phr" } }, + Description = new RichMultiString { { "en", new RichString("A phrase is a syntactic structure that consists of more than one word but lacks the subject-predicate organization of a clause.") } }, SecondaryOrder = 0, }, new() @@ -178,6 +198,7 @@ private static MorphType[] CreateAll() => Kind = MorphTypeKind.DiscontiguousPhrase, Name = new MultiString { { "en", "discontiguous phrase" } }, Abbreviation = new MultiString { { "en", "dis phr" } }, + Description = new RichMultiString { { "en", new RichString("A discontiguous phrase has discontiguous constituents which (a) are separated from each other by one or more intervening constituents, and (b) are considered either (i) syntactically contiguous and unitary, or (ii) realizing the same, single meaning. An example is French ne...pas.") } }, SecondaryOrder = 0, }, ]; From c17dda63dfd130d9e199bbd5cbbc5b2f69ba55c6 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 1 Apr 2026 13:32:50 +0700 Subject: [PATCH 12/28] Don't depend on entry order in snapshots Project snapshots for CRDT and FW are returning entries in different order, for some not-yet-identified reason. As long as the entries with the same ID compare the same, we should not fail the Sena-3 sync tests just because the snapshots have the entries in a different order. Once the tests pass, I may revert this commit and investigate why the entries in the snapshot are coming out in a different order. --- backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs index db43e941d1..83bea3883d 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs @@ -102,6 +102,7 @@ internal static void AssertSnapshotsAreEquivalent(ProjectSnapshot expected, Proj options => options .WithStrictOrdering() + .WithoutStrictOrderingFor(x => x.Entries) .WithoutStrictOrderingFor(x => x.PartsOfSpeech) .WithoutStrictOrderingFor(x => x.Publications) .WithoutStrictOrderingFor(x => x.SemanticDomains) From b2baf475ce15758073bc07569fd0c35bc5c86e9b Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 2 Apr 2026 09:29:41 +0700 Subject: [PATCH 13/28] Sort entries identically in FW and CRDT APIs No need to set "WithoutStrictOrderingFor(x -> x.Entries)" if the FW and CRDT APIs are sorting entries the same way. The difference in sorting was because the FW API had been set to sort by HomographNumber, but CRDT has not yet implemented HomographNumber so the sorting was different. Removing the sort by HomographNumber from the FW API results in the tests passing because snapshots are sorted in the same order again. --- backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs index 83bea3883d..db43e941d1 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs @@ -102,7 +102,6 @@ internal static void AssertSnapshotsAreEquivalent(ProjectSnapshot expected, Proj options => options .WithStrictOrdering() - .WithoutStrictOrderingFor(x => x.Entries) .WithoutStrictOrderingFor(x => x.PartsOfSpeech) .WithoutStrictOrderingFor(x => x.Publications) .WithoutStrictOrderingFor(x => x.SemanticDomains) From 7f131508dc8eeee3571b73ec72ceca5be0cbf24d Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 2 Apr 2026 10:28:39 +0700 Subject: [PATCH 14/28] Mention correct SecondaryOrder in test comments Test comments should reflect actual SecondaryOrder values from canonical morph types, so that we don't get confused later. --- .../MiniLcmTests/SortingTests.cs | 6 ++-- .../MiniLcmTests/SortingTests.cs | 6 ++-- .../FwLite/MiniLcm.Tests/SortingTestsBase.cs | 30 +++++++++---------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs index ce3eb0cc61..4f683afba5 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs @@ -22,9 +22,9 @@ public async Task SecondaryOrder_DefaultsToStem(string query, SortField sortFiel { var unknownMorphTypeEntryId = Guid.NewGuid(); Entry[] expected = [ - new() { Id = unknownMorphTypeEntryId, LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Unknown }, // SecondaryOrder defaults to Stem = 1 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.BoundStem }, // SecondaryOrder = 2 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 6 + new() { Id = unknownMorphTypeEntryId, LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Unknown }, // SecondaryOrder defaults to Stem = 0 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.BoundStem }, // SecondaryOrder = 10 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 70 ]; var ids = expected.Select(e => e.Id).ToHashSet(); diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs index 1f0089f470..2f31d8f559 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs @@ -25,9 +25,9 @@ public override async Task DisposeAsync() public async Task SecondaryOrder_DefaultsToStem(string query, SortField sortField) { Entry[] expected = [ - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Unknown }, // SecondaryOrder defaults to Stem = 1 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.BoundStem }, // SecondaryOrder = 2 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 6 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Unknown }, // SecondaryOrder defaults to Stem = 0 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.BoundStem }, // SecondaryOrder = 10 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 70 ]; var ids = expected.Select(e => e.Id).ToHashSet(); diff --git a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs index e4d19fbff2..f401345f23 100644 --- a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs @@ -68,9 +68,9 @@ public async Task MorphTokens_DoNotAffectSortOrder(string query, SortField sortF // Suffix: "aaaa-" (trailing token "-") // Sort order should ignore morph tokens and differentiate only by SecondaryOrder. Entry[] expected = [ - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Root }, // SecondaryOrder = 1 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Prefix }, // SecondaryOrder = 3 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 6 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Root }, // SecondaryOrder = 0 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Prefix }, // SecondaryOrder = 20 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 70 ]; var ids = expected.Select(e => e.Id).ToHashSet(); @@ -94,10 +94,10 @@ public async Task SecondaryOrder_Relevance_LexemeForm(string searchTerm) static Entry[] CreateSortedEntrySet(string headword) { return [ - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = headword }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = lexeme }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = headword }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = lexeme }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, ]; @@ -152,10 +152,10 @@ public async Task SecondaryOrder_Relevance_CitationForm(string searchTerm) static Entry[] CreateSortedEntrySet(string headword) { return [ - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = headword }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = headword }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = headword }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = headword }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, ]; @@ -208,16 +208,16 @@ static Entry[] CreateSortedEntrySet(string headword) public async Task SecondaryOrder_Headword_LexemeForm(string searchTerm) { Entry[] expected = [ - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "abaaa" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "abaaa" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "abaaa" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "abaaa" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "baaa" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "baaa" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "baaa" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "baaa" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, ]; @@ -243,16 +243,16 @@ public async Task SecondaryOrder_Headword_LexemeForm(string searchTerm) public async Task SecondaryOrder_Headword_CitationForm(string searchTerm) { Entry[] expected = [ - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "abaaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "abaaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "abaaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "abaaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "baaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "baaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "baaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "baaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, ]; From 925efa80178e0aa0f2c7d42378b2f4c638b263b2 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 2 Apr 2026 10:46:37 +0700 Subject: [PATCH 15/28] Address my own review comments --- .../FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs | 6 ++---- backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs | 11 +++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index 1c2fb60753..8157b706c5 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -106,8 +106,7 @@ public async Task CanonicalMorphTypes_MatchFwDataMorphTypes() // Verify every FwData morph type has a matching canonical entry foreach (var fwMorphType in fwDataMorphTypes) { - if (fwMorphType.Kind == MorphTypeKind.Unknown) - continue; + fwMorphType.Kind.Should().NotBe(MorphTypeKind.Unknown); CanonicalMorphTypes.All.Should().ContainKey(fwMorphType.Kind, $"canonical morph types should include {fwMorphType.Kind}"); @@ -120,10 +119,9 @@ public async Task CanonicalMorphTypes_MatchFwDataMorphTypes() // Verify every canonical morph type exists in FwData (no extras we shouldn't have) var fwDataKinds = fwDataMorphTypes - .Where(m => m.Kind != MorphTypeKind.Unknown) .Select(m => m.Kind) .ToHashSet(); - CanonicalMorphTypes.All.Keys.Should().BeSubsetOf(fwDataKinds, + CanonicalMorphTypes.All.Keys.Should().BeEquivalentTo(fwDataKinds, "every canonical morph type should exist in the Sena 3 FwData project"); } diff --git a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs index 7b23c75118..0c3d405fc1 100644 --- a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs @@ -11,7 +11,8 @@ public class MorphTypeSeedingTests [Fact] public async Task NewProjectWithSeedData_HasAllCanonicalMorphTypes() { - var sqliteFile = "MorphTypeSeed_NewProject.sqlite"; + var code = "morph-type-seed-test"; + var sqliteFile = $"{code}.sqlite"; if (File.Exists(sqliteFile)) File.Delete(sqliteFile); var builder = Host.CreateEmptyApplicationBuilder(null); builder.Services.AddTestLcmCrdtClient(); @@ -21,7 +22,7 @@ public async Task NewProjectWithSeedData_HasAllCanonicalMorphTypes() var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); var crdtProject = await crdtProjectsService.CreateProject(new( Name: "MorphTypeSeedTest", - Code: "morph-type-seed-test", + Code: code, Path: "", SeedNewProjectData: true)); @@ -47,7 +48,8 @@ public async Task NewProjectWithSeedData_HasAllCanonicalMorphTypes() [Fact] public async Task ExistingProjectWithoutMorphTypes_GetsMorphTypesOnOpen() { - var sqliteFile = "MorphTypeSeed_ExistingProject.sqlite"; + var code = "morph-type-seed-existing"; + var sqliteFile = $"{code}.sqlite"; if (File.Exists(sqliteFile)) File.Delete(sqliteFile); var builder = Host.CreateEmptyApplicationBuilder(null); builder.Services.AddTestLcmCrdtClient(); @@ -58,7 +60,7 @@ public async Task ExistingProjectWithoutMorphTypes_GetsMorphTypesOnOpen() // Create project WITHOUT seeding var crdtProject = await crdtProjectsService.CreateProject(new( Name: "MorphTypeSeedExisting", - Code: "morph-type-seed-existing", + Code: code, Path: "", SeedNewProjectData: false)); @@ -138,6 +140,7 @@ public void CanonicalMorphTypes_HaveRequiredFields() mt.Id.Should().NotBe(Guid.Empty, $"MorphType {mt.Kind} should have a non-empty Id"); mt.Name["en"].Should().NotBeNullOrWhiteSpace($"MorphType {mt.Kind} should have an English name"); mt.Abbreviation["en"].Should().NotBeNullOrWhiteSpace($"MorphType {mt.Kind} should have an English abbreviation"); + mt.Description["en"].IsEmpty.Should().BeFalse($"MorphType {mt.Kind} should have an English description"); } } } From 46e237442a72a1f77871331c9681412cbe19f342 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 6 Apr 2026 10:05:05 +0700 Subject: [PATCH 16/28] Improve MorphTypeSeedingTests a bit Check that descriptions got created since they're essential to having the correct number of changes in Sena-3 sync tests, and do a bit more to check that the SeedingIsIdempotent_OpeningProjectTwiceDoesNotDuplicate test is actually checking what we think it's checking. --- .../FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs index 0c3d405fc1..ffefa98d62 100644 --- a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs @@ -36,6 +36,8 @@ public async Task NewProjectWithSeedData_HasAllCanonicalMorphTypes() mt.Id.Should().Be(canonical.Id); mt.Name["en"].Should().Be(canonical.Name["en"]); mt.Abbreviation["en"].Should().Be(canonical.Abbreviation["en"]); + mt.Description["en"].GetPlainText().Should().Be(canonical.Description["en"].GetPlainText()); + mt.Description["en"].Spans.Should().BeEquivalentTo(canonical.Description["en"].Spans); mt.Prefix.Should().Be(canonical.Prefix); mt.Postfix.Should().Be(canonical.Postfix); mt.SecondaryOrder.Should().Be(canonical.SecondaryOrder); @@ -93,19 +95,20 @@ public async Task SeedingIsIdempotent_OpeningProjectTwiceDoesNotDuplicate() Code: code, Path: "", SeedNewProjectData: true)); - await scope.ServiceProvider.OpenCrdtProject(crdtProject); + var api = await crdtProjectsService.OpenProject(crdtProject, scope.ServiceProvider); + var morphTypes = await api.GetMorphTypes().ToArrayAsync(); + morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count, + "morph types should have been seeded"); } - // Second open: MigrateDb should detect existing morph types and skip seeding - // Note: MigrationTasks is static, so we need to clear it to re-trigger MigrateDb. - // In production, this doesn't happen (each process lifetime runs once). - // Instead, we verify by count that the seeding itself is duplicate-safe. + // Second open: morph types { await using var scope = host.Services.CreateAsyncScope(); var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); var crdtProject = crdtProjectsService.GetProject(code); crdtProject.Should().NotBeNull(); var api = await crdtProjectsService.OpenProject(crdtProject, scope.ServiceProvider); + // OpenProject calls MigrateDb(), which includes seeding morph types but only if they're not already seeded var morphTypes = await api.GetMorphTypes().ToArrayAsync(); morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count, "morph types should not be duplicated"); From bcbe1bc1b4d7b87f545e2c1b424465086fe0b025 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 6 Apr 2026 10:08:52 +0700 Subject: [PATCH 17/28] Remove RegressionVersion value we don't need yet Seeding the morph types does not actually create a change in the serialization format of project snapshots, so we don't actually need a RegressionVersion.v3 and corresponding tests for it. --- backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs index ff40d0c591..7be8eecf37 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs @@ -74,6 +74,5 @@ public enum RegressionVersion { v1, v2, - v3 } } From d04084f4e51221f77f1f71d233795aa233ec5600 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 14:55:23 +0200 Subject: [PATCH 18/28] Format --- .../MiniLcm/Models/CanonicalMorphTypes.cs | 387 +++++++++--------- 1 file changed, 195 insertions(+), 192 deletions(-) diff --git a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs index 8e8df6ccc0..627e6f8840 100644 --- a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs +++ b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs @@ -10,196 +10,199 @@ public static class CanonicalMorphTypes { public static readonly FrozenDictionary All = CreateAll().ToFrozenDictionary(m => m.Kind); - private static MorphType[] CreateAll() => - [ - new() - { - Id = new Guid("d7f713e4-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.BoundRoot, - Name = new MultiString { { "en", "bound root" } }, - Abbreviation = new MultiString { { "en", "bd root" } }, - Description = new RichMultiString { { "en", new RichString("A bound root is a root which cannot occur as a separate word apart from any other morpheme.") } }, - Prefix = "*", - SecondaryOrder = 10, - }, - new() - { - Id = new Guid("d7f713e7-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.BoundStem, - Name = new MultiString { { "en", "bound stem" } }, - Abbreviation = new MultiString { { "en", "bd stem" } }, - // Do not correct the doubled space in the next line - Description = new RichMultiString { { "en", new RichString("A bound stem is a stem which cannot occur as a separate word apart from any other morpheme.") } }, - Prefix = "*", - SecondaryOrder = 10, - }, - new() - { - Id = new Guid("d7f713df-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Circumfix, - Name = new MultiString { { "en", "circumfix" } }, - Abbreviation = new MultiString { { "en", "cfx" } }, - Description = new RichMultiString { { "en", new RichString("A circumfix is an affix made up of two separate parts which surround and attach to a root or stem.") } }, - SecondaryOrder = 0, - }, - new() - { - Id = new Guid("c2d140e5-7ca9-41f4-a69a-22fc7049dd2c"), - Kind = MorphTypeKind.Clitic, - Name = new MultiString { { "en", "clitic" } }, - Abbreviation = new MultiString { { "en", "clit" } }, - Description = new RichMultiString { { "en", new RichString("A clitic is a morpheme that has syntactic characteristics of a word, but shows evidence of being phonologically bound to another word. Orthographically, it stands alone.") } }, - SecondaryOrder = 0, - }, - new() - { - Id = new Guid("d7f713e1-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Enclitic, - Name = new MultiString { { "en", "enclitic" } }, - Abbreviation = new MultiString { { "en", "enclit" } }, - Description = new RichMultiString { { "en", new RichString("An enclitic is a clitic that is phonologically joined at the end of a preceding word to form a single unit.") } }, - Prefix = "=", - SecondaryOrder = 80, - }, - new() - { - Id = new Guid("d7f713da-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Infix, - Name = new MultiString { { "en", "infix" } }, - Abbreviation = new MultiString { { "en", "ifx" } }, - Description = new RichMultiString { { "en", new RichString("An infix is an affix that is inserted within a root or stem.") } }, - Prefix = "-", - Postfix = "-", - SecondaryOrder = 40, - }, - new() - { - Id = new Guid("56db04bf-3d58-44cc-b292-4c8aa68538f4"), - Kind = MorphTypeKind.Particle, - Name = new MultiString { { "en", "particle" } }, - Abbreviation = new MultiString { { "en", "part" } }, - Description = new RichMultiString { { "en", new RichString("A particle is a word that does not belong to one of the main classes of words, is invariable in form, and typically has grammatical or pragmatic meaning.") } }, - SecondaryOrder = 0, - }, - new() - { - Id = new Guid("d7f713db-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Prefix, - Name = new MultiString { { "en", "prefix" } }, - Abbreviation = new MultiString { { "en", "pfx" } }, - Description = new RichMultiString { { "en", new RichString("A prefix is an affix that is joined before a root or stem.") } }, - Postfix = "-", - SecondaryOrder = 20, - }, - new() - { - Id = new Guid("d7f713e2-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Proclitic, - Name = new MultiString { { "en", "proclitic" } }, - Abbreviation = new MultiString { { "en", "proclit" } }, - Description = new RichMultiString { { "en", new RichString("A proclitic is a clitic that precedes the word to which it is phonologically joined.") } }, - Postfix = "=", - SecondaryOrder = 30, - }, - new() - { - Id = new Guid("d7f713e5-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Root, - Name = new MultiString { { "en", "root" } }, - Abbreviation = new MultiString { { "en", "ubd root" } }, - Description = new RichMultiString { { "en", new RichString("A root is the portion of a word that (i) is common to a set of derived or inflected forms, if any, when all affixes are removed, (ii) is not further analyzable into meaningful elements, being morphologically simple, and, (iii) carries the principle portion of meaning of the words in which it functions.") } }, - SecondaryOrder = 0, - }, - new() - { - Id = new Guid("d7f713dc-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Simulfix, - Name = new MultiString { { "en", "simulfix" } }, - Abbreviation = new MultiString { { "en", "smfx" } }, - Description = new RichMultiString { { "en", new RichString("A simulfix is a change or replacement of vowels or consonants (usually vowels) which changes the meaning of a word. (Note: the parser does not currently handle simulfixes.)") } }, - Prefix = "=", - Postfix = "=", - SecondaryOrder = 60, - }, - new() - { - Id = new Guid("d7f713e8-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Stem, - Name = new MultiString { { "en", "stem" } }, - Abbreviation = new MultiString { { "en", "ubd stem" } }, - Description = new RichMultiString { { "en", new RichString("A stem is the root or roots of a word, together with any derivational affixes, to which inflectional affixes are added.\" (LinguaLinks Library). A stem \"may consist solely of a single root morpheme (i.e. a 'simple' stem as in 'man'), or of two root morphemes (e.g. a 'compound' stem, as in 'blackbird'), or of a root morpheme plus a derivational affix (i.e. a 'complex' stem, as in 'manly', 'unmanly', 'manliness'). All have in common the notion that it is to the stem that inflectional affixes are attached.\" (Crystal, 1997:362)") } }, - SecondaryOrder = 0, - }, - new() - { - Id = new Guid("d7f713dd-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Suffix, - Name = new MultiString { { "en", "suffix" } }, - Abbreviation = new MultiString { { "en", "sfx" } }, - Description = new RichMultiString { { "en", new RichString("A suffix is an affix that is attached to the end of a root or stem.") } }, - Prefix = "-", - SecondaryOrder = 70, - }, - new() - { - Id = new Guid("d7f713de-e8cf-11d3-9764-00c04f186933"), - Kind = MorphTypeKind.Suprafix, - Name = new MultiString { { "en", "suprafix" } }, - Abbreviation = new MultiString { { "en", "spfx" } }, - // Do not correct the doubled space in the next line - Description = new RichMultiString { { "en", new RichString("A suprafix is a kind of affix in which a suprasegmental is superimposed on one or more syllables of the root or stem, signalling a particular morphosyntactic operation. (Note: the parser does not currently handle suprafixes.)") } }, - Prefix = "~", - Postfix = "~", - SecondaryOrder = 50, - }, - new() - { - Id = new Guid("18d9b1c3-b5b6-4c07-b92c-2fe1d2281bd4"), - Kind = MorphTypeKind.InfixingInterfix, - Name = new MultiString { { "en", "infixing interfix" } }, - Abbreviation = new MultiString { { "en", "ifxnfx" } }, - Description = new RichMultiString { { "en", new RichString("An infixing interfix is an infix that can occur between two roots or stems.") } }, - Prefix = "-", - Postfix = "-", - SecondaryOrder = 0, - }, - new() - { - Id = new Guid("af6537b0-7175-4387-ba6a-36547d37fb13"), - Kind = MorphTypeKind.PrefixingInterfix, - Name = new MultiString { { "en", "prefixing interfix" } }, - Abbreviation = new MultiString { { "en", "pfxnfx" } }, - Description = new RichMultiString { { "en", new RichString("A prefixing interfix is a prefix that can occur between two roots or stems.") } }, - Postfix = "-", - SecondaryOrder = 0, - }, - new() - { - Id = new Guid("3433683d-08a9-4bae-ae53-2a7798f64068"), - Kind = MorphTypeKind.SuffixingInterfix, - Name = new MultiString { { "en", "suffixing interfix" } }, - Abbreviation = new MultiString { { "en", "sfxnfx" } }, - Description = new RichMultiString { { "en", new RichString("A suffixing interfix is an suffix that can occur between two roots or stems.") } }, - Prefix = "-", - SecondaryOrder = 0, - }, - new() - { - Id = new Guid("a23b6faa-1052-4f4d-984b-4b338bdaf95f"), - Kind = MorphTypeKind.Phrase, - Name = new MultiString { { "en", "phrase" } }, - Abbreviation = new MultiString { { "en", "phr" } }, - Description = new RichMultiString { { "en", new RichString("A phrase is a syntactic structure that consists of more than one word but lacks the subject-predicate organization of a clause.") } }, - SecondaryOrder = 0, - }, - new() - { - Id = new Guid("0cc8c35a-cee9-434d-be58-5d29130fba5b"), - Kind = MorphTypeKind.DiscontiguousPhrase, - Name = new MultiString { { "en", "discontiguous phrase" } }, - Abbreviation = new MultiString { { "en", "dis phr" } }, - Description = new RichMultiString { { "en", new RichString("A discontiguous phrase has discontiguous constituents which (a) are separated from each other by one or more intervening constituents, and (b) are considered either (i) syntactically contiguous and unitary, or (ii) realizing the same, single meaning. An example is French ne...pas.") } }, - SecondaryOrder = 0, - }, - ]; + private static MorphType[] CreateAll() + { + return + [ + new() + { + Id = new Guid("d7f713e4-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.BoundRoot, + Name = new MultiString { { "en", "bound root" } }, + Abbreviation = new MultiString { { "en", "bd root" } }, + Description = new RichMultiString { { "en", new RichString("A bound root is a root which cannot occur as a separate word apart from any other morpheme.") } }, + Prefix = "*", + SecondaryOrder = 10, + }, + new() + { + Id = new Guid("d7f713e7-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.BoundStem, + Name = new MultiString { { "en", "bound stem" } }, + Abbreviation = new MultiString { { "en", "bd stem" } }, + // Do not correct the doubled space in the next line + Description = new RichMultiString { { "en", new RichString("A bound stem is a stem which cannot occur as a separate word apart from any other morpheme.") } }, + Prefix = "*", + SecondaryOrder = 10, + }, + new() + { + Id = new Guid("d7f713df-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Circumfix, + Name = new MultiString { { "en", "circumfix" } }, + Abbreviation = new MultiString { { "en", "cfx" } }, + Description = new RichMultiString { { "en", new RichString("A circumfix is an affix made up of two separate parts which surround and attach to a root or stem.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("c2d140e5-7ca9-41f4-a69a-22fc7049dd2c"), + Kind = MorphTypeKind.Clitic, + Name = new MultiString { { "en", "clitic" } }, + Abbreviation = new MultiString { { "en", "clit" } }, + Description = new RichMultiString { { "en", new RichString("A clitic is a morpheme that has syntactic characteristics of a word, but shows evidence of being phonologically bound to another word. Orthographically, it stands alone.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("d7f713e1-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Enclitic, + Name = new MultiString { { "en", "enclitic" } }, + Abbreviation = new MultiString { { "en", "enclit" } }, + Description = new RichMultiString { { "en", new RichString("An enclitic is a clitic that is phonologically joined at the end of a preceding word to form a single unit.") } }, + Prefix = "=", + SecondaryOrder = 80, + }, + new() + { + Id = new Guid("d7f713da-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Infix, + Name = new MultiString { { "en", "infix" } }, + Abbreviation = new MultiString { { "en", "ifx" } }, + Description = new RichMultiString { { "en", new RichString("An infix is an affix that is inserted within a root or stem.") } }, + Prefix = "-", + Postfix = "-", + SecondaryOrder = 40, + }, + new() + { + Id = new Guid("56db04bf-3d58-44cc-b292-4c8aa68538f4"), + Kind = MorphTypeKind.Particle, + Name = new MultiString { { "en", "particle" } }, + Abbreviation = new MultiString { { "en", "part" } }, + Description = new RichMultiString { { "en", new RichString("A particle is a word that does not belong to one of the main classes of words, is invariable in form, and typically has grammatical or pragmatic meaning.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("d7f713db-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Prefix, + Name = new MultiString { { "en", "prefix" } }, + Abbreviation = new MultiString { { "en", "pfx" } }, + Description = new RichMultiString { { "en", new RichString("A prefix is an affix that is joined before a root or stem.") } }, + Postfix = "-", + SecondaryOrder = 20, + }, + new() + { + Id = new Guid("d7f713e2-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Proclitic, + Name = new MultiString { { "en", "proclitic" } }, + Abbreviation = new MultiString { { "en", "proclit" } }, + Description = new RichMultiString { { "en", new RichString("A proclitic is a clitic that precedes the word to which it is phonologically joined.") } }, + Postfix = "=", + SecondaryOrder = 30, + }, + new() + { + Id = new Guid("d7f713e5-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Root, + Name = new MultiString { { "en", "root" } }, + Abbreviation = new MultiString { { "en", "ubd root" } }, + Description = new RichMultiString { { "en", new RichString("A root is the portion of a word that (i) is common to a set of derived or inflected forms, if any, when all affixes are removed, (ii) is not further analyzable into meaningful elements, being morphologically simple, and, (iii) carries the principle portion of meaning of the words in which it functions.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("d7f713dc-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Simulfix, + Name = new MultiString { { "en", "simulfix" } }, + Abbreviation = new MultiString { { "en", "smfx" } }, + Description = new RichMultiString { { "en", new RichString("A simulfix is a change or replacement of vowels or consonants (usually vowels) which changes the meaning of a word. (Note: the parser does not currently handle simulfixes.)") } }, + Prefix = "=", + Postfix = "=", + SecondaryOrder = 60, + }, + new() + { + Id = new Guid("d7f713e8-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Stem, + Name = new MultiString { { "en", "stem" } }, + Abbreviation = new MultiString { { "en", "ubd stem" } }, + Description = new RichMultiString { { "en", new RichString("A stem is the root or roots of a word, together with any derivational affixes, to which inflectional affixes are added.\" (LinguaLinks Library). A stem \"may consist solely of a single root morpheme (i.e. a 'simple' stem as in 'man'), or of two root morphemes (e.g. a 'compound' stem, as in 'blackbird'), or of a root morpheme plus a derivational affix (i.e. a 'complex' stem, as in 'manly', 'unmanly', 'manliness'). All have in common the notion that it is to the stem that inflectional affixes are attached.\" (Crystal, 1997:362)") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("d7f713dd-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Suffix, + Name = new MultiString { { "en", "suffix" } }, + Abbreviation = new MultiString { { "en", "sfx" } }, + Description = new RichMultiString { { "en", new RichString("A suffix is an affix that is attached to the end of a root or stem.") } }, + Prefix = "-", + SecondaryOrder = 70, + }, + new() + { + Id = new Guid("d7f713de-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Suprafix, + Name = new MultiString { { "en", "suprafix" } }, + Abbreviation = new MultiString { { "en", "spfx" } }, + // Do not correct the doubled space in the next line + Description = new RichMultiString { { "en", new RichString("A suprafix is a kind of affix in which a suprasegmental is superimposed on one or more syllables of the root or stem, signalling a particular morphosyntactic operation. (Note: the parser does not currently handle suprafixes.)") } }, + Prefix = "~", + Postfix = "~", + SecondaryOrder = 50, + }, + new() + { + Id = new Guid("18d9b1c3-b5b6-4c07-b92c-2fe1d2281bd4"), + Kind = MorphTypeKind.InfixingInterfix, + Name = new MultiString { { "en", "infixing interfix" } }, + Abbreviation = new MultiString { { "en", "ifxnfx" } }, + Description = new RichMultiString { { "en", new RichString("An infixing interfix is an infix that can occur between two roots or stems.") } }, + Prefix = "-", + Postfix = "-", + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("af6537b0-7175-4387-ba6a-36547d37fb13"), + Kind = MorphTypeKind.PrefixingInterfix, + Name = new MultiString { { "en", "prefixing interfix" } }, + Abbreviation = new MultiString { { "en", "pfxnfx" } }, + Description = new RichMultiString { { "en", new RichString("A prefixing interfix is a prefix that can occur between two roots or stems.") } }, + Postfix = "-", + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("3433683d-08a9-4bae-ae53-2a7798f64068"), + Kind = MorphTypeKind.SuffixingInterfix, + Name = new MultiString { { "en", "suffixing interfix" } }, + Abbreviation = new MultiString { { "en", "sfxnfx" } }, + Description = new RichMultiString { { "en", new RichString("A suffixing interfix is an suffix that can occur between two roots or stems.") } }, + Prefix = "-", + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("a23b6faa-1052-4f4d-984b-4b338bdaf95f"), + Kind = MorphTypeKind.Phrase, + Name = new MultiString { { "en", "phrase" } }, + Abbreviation = new MultiString { { "en", "phr" } }, + Description = new RichMultiString { { "en", new RichString("A phrase is a syntactic structure that consists of more than one word but lacks the subject-predicate organization of a clause.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("0cc8c35a-cee9-434d-be58-5d29130fba5b"), + Kind = MorphTypeKind.DiscontiguousPhrase, + Name = new MultiString { { "en", "discontiguous phrase" } }, + Abbreviation = new MultiString { { "en", "dis phr" } }, + Description = new RichMultiString { { "en", new RichString("A discontiguous phrase has discontiguous constituents which (a) are separated from each other by one or more intervening constituents, and (b) are considered either (i) syntactically contiguous and unitary, or (ii) realizing the same, single meaning. An example is French ne...pas.") } }, + SecondaryOrder = 0, + }, + ]; + } } From a931a7d420ac1b870990ac898433fd782db922bb Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 14:55:32 +0200 Subject: [PATCH 19/28] Update comment --- backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs index 627e6f8840..02692c639c 100644 --- a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs +++ b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs @@ -3,8 +3,8 @@ namespace MiniLcm.Models; /// -/// Canonical morph-type definitions matching FieldWorks/LibLCM MoMorphTypeTags. -/// GUIDs match SIL.LCModel constants (kguidMorph*). Data verified against Sena 3 FwData project. +/// Canonical morph-type definitions copied from: +/// https://github.com/sillsdev/liblcm/blob/master/src/SIL.LCModel/Templates/NewLangProj.fwdata /// public static class CanonicalMorphTypes { From fa2d764c6d9926ed242d7a8364944e6712371e14 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 15:10:52 +0200 Subject: [PATCH 20/28] Update morph-type descriptions --- .../MiniLcm/Models/CanonicalMorphTypes.cs | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs index 02692c639c..fd84b961f2 100644 --- a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs +++ b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs @@ -30,8 +30,7 @@ private static MorphType[] CreateAll() Kind = MorphTypeKind.BoundStem, Name = new MultiString { { "en", "bound stem" } }, Abbreviation = new MultiString { { "en", "bd stem" } }, - // Do not correct the doubled space in the next line - Description = new RichMultiString { { "en", new RichString("A bound stem is a stem which cannot occur as a separate word apart from any other morpheme.") } }, + Description = new RichMultiString { { "en", new RichString("A bound stem is a stem which cannot occur as a separate word apart from any other morpheme.") } }, Prefix = "*", SecondaryOrder = 10, }, @@ -59,7 +58,7 @@ private static MorphType[] CreateAll() Kind = MorphTypeKind.Enclitic, Name = new MultiString { { "en", "enclitic" } }, Abbreviation = new MultiString { { "en", "enclit" } }, - Description = new RichMultiString { { "en", new RichString("An enclitic is a clitic that is phonologically joined at the end of a preceding word to form a single unit.") } }, + Description = new RichMultiString { { "en", new RichString("An enclitic is a clitic that is phonologically joined at the end of a preceding word to form a single unit. Orthographically, it may attach to the preceding word.") } }, Prefix = "=", SecondaryOrder = 80, }, @@ -99,7 +98,7 @@ private static MorphType[] CreateAll() Kind = MorphTypeKind.Proclitic, Name = new MultiString { { "en", "proclitic" } }, Abbreviation = new MultiString { { "en", "proclit" } }, - Description = new RichMultiString { { "en", new RichString("A proclitic is a clitic that precedes the word to which it is phonologically joined.") } }, + Description = new RichMultiString { { "en", new RichString("A proclitic is a clitic that precedes the word to which it is phonologically joined. Orthographically, it may attach to the following word.") } }, Postfix = "=", SecondaryOrder = 30, }, @@ -109,7 +108,7 @@ private static MorphType[] CreateAll() Kind = MorphTypeKind.Root, Name = new MultiString { { "en", "root" } }, Abbreviation = new MultiString { { "en", "ubd root" } }, - Description = new RichMultiString { { "en", new RichString("A root is the portion of a word that (i) is common to a set of derived or inflected forms, if any, when all affixes are removed, (ii) is not further analyzable into meaningful elements, being morphologically simple, and, (iii) carries the principle portion of meaning of the words in which it functions.") } }, + Description = new RichMultiString { { "en", new RichString("A root is the portion of a word that (i) is common to a set of derived or inflected forms, if any, when all affixes are removed, (ii) is not further analyzable into meaningful elements, being morphologically simple, and, (iii) carries the principal portion of meaning of the words in which it functions.") } }, SecondaryOrder = 0, }, new() @@ -118,7 +117,7 @@ private static MorphType[] CreateAll() Kind = MorphTypeKind.Simulfix, Name = new MultiString { { "en", "simulfix" } }, Abbreviation = new MultiString { { "en", "smfx" } }, - Description = new RichMultiString { { "en", new RichString("A simulfix is a change or replacement of vowels or consonants (usually vowels) which changes the meaning of a word. (Note: the parser does not currently handle simulfixes.)") } }, + Description = new RichMultiString { { "en", new RichString("A simulfix is a change or replacement of vowels or consonants (usually vowels) which changes the meaning of a word. (Note: the parser does not currently handle simulfixes.)") } }, Prefix = "=", Postfix = "=", SecondaryOrder = 60, @@ -129,7 +128,19 @@ private static MorphType[] CreateAll() Kind = MorphTypeKind.Stem, Name = new MultiString { { "en", "stem" } }, Abbreviation = new MultiString { { "en", "ubd stem" } }, - Description = new RichMultiString { { "en", new RichString("A stem is the root or roots of a word, together with any derivational affixes, to which inflectional affixes are added.\" (LinguaLinks Library). A stem \"may consist solely of a single root morpheme (i.e. a 'simple' stem as in 'man'), or of two root morphemes (e.g. a 'compound' stem, as in 'blackbird'), or of a root morpheme plus a derivational affix (i.e. a 'complex' stem, as in 'manly', 'unmanly', 'manliness'). All have in common the notion that it is to the stem that inflectional affixes are attached.\" (Crystal, 1997:362)") } }, + Description = new RichMultiString { { "en", new RichString([ + new RichSpan { Text = "\"A stem is the root or roots of a word, together with any derivational affixes, to which inflectional affixes are added.\" (LinguaLinks Library). A stem \"may consist solely of a single root morpheme (i.e. a 'simple' stem as in " }, + new RichSpan { Text = "man", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = "), or of two root morphemes (e.g. a 'compound' stem, as in " }, + new RichSpan { Text = "blackbird", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = "), or of a root morpheme plus a derivational affix (i.e. a 'complex' stem, as in " }, + new RichSpan { Text = "manly", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = ", " }, + new RichSpan { Text = "unmanly", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = ", " }, + new RichSpan { Text = "manliness", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = "). All have in common the notion that it is to the stem that inflectional affixes are attached.\" (Crystal, 1997:362)" }, + ]) } }, SecondaryOrder = 0, }, new() @@ -148,8 +159,7 @@ private static MorphType[] CreateAll() Kind = MorphTypeKind.Suprafix, Name = new MultiString { { "en", "suprafix" } }, Abbreviation = new MultiString { { "en", "spfx" } }, - // Do not correct the doubled space in the next line - Description = new RichMultiString { { "en", new RichString("A suprafix is a kind of affix in which a suprasegmental is superimposed on one or more syllables of the root or stem, signalling a particular morphosyntactic operation. (Note: the parser does not currently handle suprafixes.)") } }, + Description = new RichMultiString { { "en", new RichString("A suprafix is a kind of affix in which a suprasegmental is superimposed on one or more syllables of the root or stem, signalling a particular morphosyntactic operation. (Note: the parser does not currently handle suprafixes.)") } }, Prefix = "~", Postfix = "~", SecondaryOrder = 50, @@ -181,7 +191,7 @@ private static MorphType[] CreateAll() Kind = MorphTypeKind.SuffixingInterfix, Name = new MultiString { { "en", "suffixing interfix" } }, Abbreviation = new MultiString { { "en", "sfxnfx" } }, - Description = new RichMultiString { { "en", new RichString("A suffixing interfix is an suffix that can occur between two roots or stems.") } }, + Description = new RichMultiString { { "en", new RichString("A suffixing interfix is a suffix that can occur between two roots or stems.") } }, Prefix = "-", SecondaryOrder = 0, }, From 32e39f833062bd8c0e023c8f37d4f438b8394fe0 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 15:33:23 +0200 Subject: [PATCH 21/28] Sync morph-types when importing, because they already exist in CRDT --- backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs b/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs index 574dd10b12..f1a97956b8 100644 --- a/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs +++ b/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs @@ -8,6 +8,7 @@ using MiniLcm; using MiniLcm.Models; using MiniLcm.Project; +using MiniLcm.SyncHelpers; namespace FwLiteProjectSync; @@ -70,6 +71,11 @@ public async Task ImportProject(IMiniLcmApi importTo, IMiniLcmApi importFrom, in logger.LogInformation("Imported complex form type {Id}", complexFormType.Id); } + // Morph types are created automatically for CRDT projects, so we update them instead of creating them + var importFromMorphTypes = await importFrom.GetMorphTypes().ToArrayAsync(); + var existingMorphTypes = await importTo.GetMorphTypes().ToArrayAsync(); + await MorphTypeSync.Sync(existingMorphTypes, importFromMorphTypes, importTo); + await foreach (var morphType in importFrom.GetMorphTypes()) { await importTo.CreateMorphType(morphType); From 9e753c227eee7f47b400dc65521cb3dc2c2028de Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 15:33:40 +0200 Subject: [PATCH 22/28] Simplify test --- .../LcmCrdt.Tests/MorphTypeSeedingTests.cs | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs index ffefa98d62..f3790e67da 100644 --- a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs @@ -1,8 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using MiniLcm.Models; -using static LcmCrdt.CrdtProjectsService; namespace LcmCrdt.Tests; @@ -29,19 +27,7 @@ public async Task NewProjectWithSeedData_HasAllCanonicalMorphTypes() var api = (CrdtMiniLcmApi)await scope.ServiceProvider.OpenCrdtProject(crdtProject); var morphTypes = await api.GetMorphTypes().ToArrayAsync(); - morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count); - foreach (var canonical in CanonicalMorphTypes.All.Values) - { - var mt = morphTypes.Should().ContainSingle(m => m.Kind == canonical.Kind).Subject; - mt.Id.Should().Be(canonical.Id); - mt.Name["en"].Should().Be(canonical.Name["en"]); - mt.Abbreviation["en"].Should().Be(canonical.Abbreviation["en"]); - mt.Description["en"].GetPlainText().Should().Be(canonical.Description["en"].GetPlainText()); - mt.Description["en"].Spans.Should().BeEquivalentTo(canonical.Description["en"].Spans); - mt.Prefix.Should().Be(canonical.Prefix); - mt.Postfix.Should().Be(canonical.Postfix); - mt.SecondaryOrder.Should().Be(canonical.SecondaryOrder); - } + morphTypes.Should().BeEquivalentTo(CanonicalMorphTypes.All.Values); await using var dbContext = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); await dbContext.Database.EnsureDeletedAsync(); From b37f16f957a35e3a1443c3137a2d8af5a7b9bfee Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 15:48:45 +0200 Subject: [PATCH 23/28] Verify our canonical morph-types match new fwdata projects --- .../CanonicalMorphTypeTests.cs | 49 +++++++++++++++++++ .../FwLiteProjectSync.Tests/Sena3SyncTests.cs | 29 ----------- 2 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs new file mode 100644 index 0000000000..ef076d8717 --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs @@ -0,0 +1,49 @@ +using FwDataMiniLcmBridge.Api; +using FwDataMiniLcmBridge.LcmUtils; +using FwDataMiniLcmBridge.Tests.Fixtures; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using MiniLcm.Models; + +namespace FwDataMiniLcmBridge.Tests; + +public class CanonicalMorphTypeTests : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly FwDataMiniLcmApi _api; + private readonly FwDataProject _project; + + public CanonicalMorphTypeTests() + { + var services = new ServiceCollection() + .AddTestFwDataBridge(mockProjectLoader: false) + .BuildServiceProvider(); + _serviceProvider = services; + + var config = services.GetRequiredService>(); + Directory.CreateDirectory(config.Value.ProjectsFolder); + var projectName = $"canonical-morph-types-test_{Guid.NewGuid()}"; + _project = new FwDataProject(projectName, config.Value.ProjectsFolder); + var projectLoader = services.GetRequiredService(); + projectLoader.NewProject(_project, "en", "en"); + + var fwDataFactory = services.GetRequiredService(); + _api = fwDataFactory.GetFwDataMiniLcmApi(_project, false); + } + + public void Dispose() + { + _api.Dispose(); + _serviceProvider.Dispose(); + if (Directory.Exists(_project.ProjectFolder)) + Directory.Delete(_project.ProjectFolder, true); + } + + [Fact] + public async Task CanonicalMorphTypes_MatchNewLangProjMorphTypes() + { + var libLcmMorphTypes = await _api.GetMorphTypes().ToArrayAsync(); + libLcmMorphTypes.Should().NotBeEmpty(); + CanonicalMorphTypes.All.Values.Should().BeEquivalentTo(libLcmMorphTypes); + } +} diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index 8157b706c5..1b6a71f1ea 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -96,35 +96,6 @@ private async Task WorkaroundMissingWritingSystems() } - [Fact] - [Trait("Category", "Integration")] - public async Task CanonicalMorphTypes_MatchFwDataMorphTypes() - { - var fwDataMorphTypes = await _fwDataApi.GetMorphTypes().ToArrayAsync(); - fwDataMorphTypes.Should().NotBeEmpty("Sena 3 should have morph types"); - - // Verify every FwData morph type has a matching canonical entry - foreach (var fwMorphType in fwDataMorphTypes) - { - fwMorphType.Kind.Should().NotBe(MorphTypeKind.Unknown); - - CanonicalMorphTypes.All.Should().ContainKey(fwMorphType.Kind, - $"canonical morph types should include {fwMorphType.Kind}"); - var canonical = CanonicalMorphTypes.All[fwMorphType.Kind]; - canonical.Id.Should().Be(fwMorphType.Id, $"GUID for {fwMorphType.Kind} should match FwData"); - canonical.Prefix.Should().Be(fwMorphType.Prefix, $"Prefix for {fwMorphType.Kind} should match FwData"); - canonical.Postfix.Should().Be(fwMorphType.Postfix, $"Postfix for {fwMorphType.Kind} should match FwData"); - canonical.SecondaryOrder.Should().Be(fwMorphType.SecondaryOrder, $"SecondaryOrder for {fwMorphType.Kind} should match FwData"); - } - - // Verify every canonical morph type exists in FwData (no extras we shouldn't have) - var fwDataKinds = fwDataMorphTypes - .Select(m => m.Kind) - .ToHashSet(); - CanonicalMorphTypes.All.Keys.Should().BeEquivalentTo(fwDataKinds, - "every canonical morph type should exist in the Sena 3 FwData project"); - } - [Fact] [Trait("Category", "Integration")] public async Task DryRunImport_MakesNoChanges() From bb92b7c36243bc690ec5e35812dcf61ac78d8134 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 16:00:00 +0200 Subject: [PATCH 24/28] Fix verified files --- ...pshotDeserializationRegressionData.latest.verified.txt | 2 +- ...DataModelSnapshotTests.VerifyChangeModels.verified.txt | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt index d1b59f5016..fccae78d32 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt @@ -1613,7 +1613,7 @@ "Analysis": null }, "Id": "72bcb2dd-4d73-486c-d5df-e5d7b9a6a3eb", - "DeletedAt": null, + "DeletedAt": null }, { "$type": "MiniLcmCrdtAdapter", diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt index 6a4e0fd2c9..da6ba4d3e5 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt @@ -192,6 +192,14 @@ DerivedType: CreateCustomViewChange, TypeDiscriminator: CreateCustomViewChange }, + { + DerivedType: EditCustomViewChange, + TypeDiscriminator: EditCustomViewChange + }, + { + DerivedType: DeleteChange, + TypeDiscriminator: delete:CustomView + }, { DerivedType: CreateMorphTypeChange, TypeDiscriminator: CreateMorphTypeChange From 228c1537ffff688f9a5325790543697cf6e975fe Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 16:27:54 +0200 Subject: [PATCH 25/28] Remove uninteresting tests --- .../FwLiteProjectSync.Tests/SyncTests.cs | 28 ------------------- .../LcmCrdt.Tests/MorphTypeSeedingTests.cs | 7 ----- 2 files changed, 35 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs index db43e941d1..b37237d59f 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs @@ -678,32 +678,4 @@ public async Task CanCreateAComplexFormTypeAndSyncsIt() _fixture.FwDataApi.GetComplexFormTypes().ToBlockingEnumerable().Should().ContainEquivalentOf(complexFormEntry); } - - [Fact] - [Trait("Category", "Integration")] - public async Task SyncWithLegacySnapshot_EmptyMorphTypes_DoesNotDuplicate() - { - var crdtApi = _fixture.CrdtApi; - var fwdataApi = _fixture.FwDataApi; - - // First sync: import so both sides have data - await _syncService.Import(crdtApi, fwdataApi); - var snapshot = await _fixture.RegenerateAndGetSnapshot(); - - // Simulate a legacy snapshot by clearing MorphTypes - var legacySnapshot = snapshot with { MorphTypes = [] }; - - // The CRDT should already have morph types (from seeding in MigrateDb). - // Syncing with a legacy snapshot should patch the snapshot and not duplicate morph types. - var syncResult = await _syncService.Sync(crdtApi, fwdataApi, legacySnapshot); - - // Verify no duplicates - var crdtMorphTypes = await crdtApi.GetMorphTypes().ToArrayAsync(); - crdtMorphTypes.Should().OnlyHaveUniqueItems(mt => mt.Kind); - crdtMorphTypes.Should().NotBeEmpty(); - - // Verify no morph-type changes were needed (they were patched from CRDT) - syncResult.CrdtChanges.Should().Be(0); - syncResult.FwdataChanges.Should().Be(0); - } } diff --git a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs index f3790e67da..74f7cdd4d4 100644 --- a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs @@ -114,13 +114,6 @@ public void CanonicalMorphTypes_CoverAllKindsExceptUnknown() CanonicalMorphTypes.All.Keys.Should().BeEquivalentTo(allKinds); } - [Fact] - public void CanonicalMorphTypes_HaveUniqueIds() - { - var ids = CanonicalMorphTypes.All.Values.Select(m => m.Id).ToList(); - ids.Should().OnlyHaveUniqueItems(); - } - [Fact] public void CanonicalMorphTypes_HaveRequiredFields() { From 1b319ec0a3ec8abd3027d227af813486d5abe927 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 16:28:03 +0200 Subject: [PATCH 26/28] Minor refactor --- .../FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs | 1 - backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs | 1 + backend/FwLite/LcmCrdt/CurrentProjectService.cs | 2 +- backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs | 3 +-- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs index 2a2f074147..1c1f655e44 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs @@ -1,4 +1,3 @@ -using LcmCrdt.Objects; using LcmCrdt.Tests; using Microsoft.Extensions.Logging.Abstractions; using MiniLcm; diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index da19839223..7f68adc8db 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -76,6 +76,7 @@ public async Task InitializeAsync(string projectName) await CrdtProjectsService.InitProjectDb(_crdtDbContext, projectData); await currentProjectService.RefreshProjectData(); + // also need to manually add morph-types, because we're not using CreateProject await PreDefinedData.PredefinedMorphTypes(_services.ServiceProvider.GetRequiredService(), projectData.ClientId); if (_seedWs) { diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index 5096895afd..7904721cdb 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -119,7 +119,7 @@ async Task Execute() if (EntrySearchServiceFactory is not null) { - await using var ess = EntrySearchServiceFactory.CreateSearchService(dbContext); + await using var ess = EntrySearchServiceFactory.CreateSearchService(dbContext); await ess.RegenerateIfMissing(); } } diff --git a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs index bb83842b83..682d77b75a 100644 --- a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs +++ b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs @@ -1,5 +1,4 @@ using LcmCrdt.Changes; -using MiniLcm.Models; using SIL.Harmony; namespace LcmCrdt.Objects; @@ -78,7 +77,7 @@ await dataModel.AddChanges(clientId, internal static async Task PredefinedMorphTypes(DataModel dataModel, Guid clientId) { await dataModel.AddChanges(clientId, - CanonicalMorphTypes.All.Values.Select(mt => new CreateMorphTypeChange(mt)).ToArray(), + [.. CanonicalMorphTypes.All.Values.Select(mt => new CreateMorphTypeChange(mt))], new Guid("a7b2c3d4-e5f6-4a8b-9c0d-1e2f3a4b5c6d")); } } From 73ae74120bf9593c249243eb03c3b64b164eeeff Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 17:37:36 +0200 Subject: [PATCH 27/28] Make NewLangProj.fwdata available to test. --- .../FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs | 2 ++ .../FwDataMiniLcmBridge.Tests.csproj | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs index ef076d8717..a6cd01abc3 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs @@ -17,6 +17,8 @@ public CanonicalMorphTypeTests() { var services = new ServiceCollection() .AddTestFwDataBridge(mockProjectLoader: false) + .PostConfigure(config => + config.TemplatesFolder = Path.GetFullPath("Templates")) .BuildServiceProvider(); _serviceProvider = services; diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj index 59d1406e12..c0d6703a59 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj @@ -35,5 +35,9 @@ + + - \ No newline at end of file + From 8a43b195a31265a5b7d7752adaa6fc1a0e7bfe9b Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Apr 2026 17:54:50 +0200 Subject: [PATCH 28/28] Fix non-FTS relevance order with morph-tokens in query --- .../FwLite/FwDataMiniLcmBridge/Api/Sorting.cs | 14 +++++--- backend/FwLite/LcmCrdt/Data/Sorting.cs | 8 ++--- .../FwLite/MiniLcm.Tests/SortingTestsBase.cs | 35 +++++++++++++++++-- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/Sorting.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/Sorting.cs index d021181e24..6f2ea07980 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/Sorting.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/Sorting.cs @@ -33,12 +33,16 @@ public static IEnumerable ApplyHeadwordOrder(this IEnumerable public static IEnumerable ApplyRoughBestMatchOrder(this IEnumerable entries, SortOptions order, int sortWsHandle, int stemSecondaryOrder, string? query = null) { - var projected = entries.Select(e => (Entry: e, Headword: e.LexEntryHeadword(sortWsHandle, applyMorphTokens: false))); + var projected = entries.Select(e => ( + Entry: e, + Headword: e.LexEntryHeadword(sortWsHandle, applyMorphTokens: false), + HeadwordWithTokens: e.LexEntryHeadword(sortWsHandle, applyMorphTokens: true) + )); if (order.Ascending) { return projected - .OrderByDescending(x => !string.IsNullOrEmpty(query) && (x.Headword?.StartsWithDiacriticMatch(query!) ?? false)) - .ThenByDescending(x => !string.IsNullOrEmpty(query) && (x.Headword?.ContainsDiacriticMatch(query!) ?? false)) + .OrderByDescending(x => !string.IsNullOrEmpty(query) && (x.HeadwordWithTokens?.StartsWithDiacriticMatch(query!) ?? false)) + .ThenByDescending(x => !string.IsNullOrEmpty(query) && (x.HeadwordWithTokens?.ContainsDiacriticMatch(query!) ?? false)) .ThenBy(x => x.Headword?.Length ?? 0) .ThenBy(x => x.Headword) .ThenBy(x => x.Entry.PrimaryMorphType?.SecondaryOrder ?? stemSecondaryOrder) @@ -49,8 +53,8 @@ public static IEnumerable ApplyRoughBestMatchOrder(this IEnumerable !string.IsNullOrEmpty(query) && (x.Headword?.StartsWithDiacriticMatch(query!) ?? false)) - .ThenBy(x => !string.IsNullOrEmpty(query) && (x.Headword?.ContainsDiacriticMatch(query!) ?? false)) + .OrderBy(x => !string.IsNullOrEmpty(query) && (x.HeadwordWithTokens?.StartsWithDiacriticMatch(query!) ?? false)) + .ThenBy(x => !string.IsNullOrEmpty(query) && (x.HeadwordWithTokens?.ContainsDiacriticMatch(query!) ?? false)) .ThenByDescending(x => x.Headword?.Length ?? 0) .ThenByDescending(x => x.Headword) .ThenByDescending(x => x.Entry.PrimaryMorphType?.SecondaryOrder ?? stemSecondaryOrder) diff --git a/backend/FwLite/LcmCrdt/Data/Sorting.cs b/backend/FwLite/LcmCrdt/Data/Sorting.cs index acb0690737..6a26954438 100644 --- a/backend/FwLite/LcmCrdt/Data/Sorting.cs +++ b/backend/FwLite/LcmCrdt/Data/Sorting.cs @@ -48,8 +48,8 @@ from e in entries join mt in morphTypes on e.MorphType equals mt.Kind into mtGroup from mt in mtGroup.DefaultIfEmpty() orderby - !string.IsNullOrEmpty(query) && SqlHelpers.StartsWithIgnoreCaseAccents(e.Headword(order.WritingSystem), query!) descending, - !string.IsNullOrEmpty(query) && SqlHelpers.ContainsIgnoreCaseAccents(e.Headword(order.WritingSystem), query!) descending, + !string.IsNullOrEmpty(query) && SqlHelpers.StartsWithIgnoreCaseAccents(e.HeadwordWithTokens(order.WritingSystem, mt.Prefix, mt.Postfix), query!) descending, + !string.IsNullOrEmpty(query) && SqlHelpers.ContainsIgnoreCaseAccents(e.HeadwordWithTokens(order.WritingSystem, mt.Prefix, mt.Postfix), query!) descending, e.Headword(order.WritingSystem).Length, e.Headword(order.WritingSystem), mt != null ? mt.SecondaryOrder : stemOrder.FirstOrDefault(), @@ -64,8 +64,8 @@ from e in entries join mt in morphTypes on e.MorphType equals mt.Kind into mtGroup from mt in mtGroup.DefaultIfEmpty() orderby - !string.IsNullOrEmpty(query) && SqlHelpers.StartsWithIgnoreCaseAccents(e.Headword(order.WritingSystem), query!), - !string.IsNullOrEmpty(query) && SqlHelpers.ContainsIgnoreCaseAccents(e.Headword(order.WritingSystem), query!), + !string.IsNullOrEmpty(query) && SqlHelpers.StartsWithIgnoreCaseAccents(e.HeadwordWithTokens(order.WritingSystem, mt.Prefix, mt.Postfix), query!), + !string.IsNullOrEmpty(query) && SqlHelpers.ContainsIgnoreCaseAccents(e.HeadwordWithTokens(order.WritingSystem, mt.Prefix, mt.Postfix), query!), e.Headword(order.WritingSystem).Length descending, e.Headword(order.WritingSystem) descending, (mt != null ? mt.SecondaryOrder : stemOrder.FirstOrDefault()) descending, diff --git a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs index f401345f23..48ab0f8f97 100644 --- a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs @@ -55,6 +55,35 @@ await Api.GetEntries(new QueryOptions(new SortOptions(SortField.Headword, wsId)) .ToArrayAsync(); } + [Theory] + [InlineData("a-", SortField.Headword)] // non-FTS + [InlineData("a-", SortField.SearchRelevance)] // non-FTS + [InlineData("aaaa-", SortField.Headword)] // FTS + [InlineData("aaaa-", SortField.SearchRelevance)] // FTS + public async Task MorphTokenSearch_PrefixHeadwordBeatsIncidentalContains(string query, SortField sortField) + { + // An entry with lexeme "a" and MorphType=Prefix has headword "a-". + // An entry "toma-toma" incidentally contains "a-" in the middle. + // The prefix entry should sort first because it's a headword-starts-with match, + // not just an incidental contains match. + var baseForm = query.TrimEnd('-'); + Entry prefixEntry = new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = baseForm }, MorphType = MorphTypeKind.Prefix }; + Entry containsEntry = new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = $"tom{baseForm}-tom" }, MorphType = MorphTypeKind.Root }; + + var ids = new[] { prefixEntry.Id, containsEntry.Id }.ToHashSet(); + + // Insert in reverse order to ensure sorting is actually tested + await Api.CreateEntry(containsEntry); + await Api.CreateEntry(prefixEntry); + + var results = (await Api.SearchEntries(query, new(new(sortField))).ToArrayAsync()) + .Where(e => ids.Contains(e.Id)) + .ToList(); + + results.Should().BeEquivalentTo([prefixEntry, containsEntry], + options => options.WithStrictOrdering()); + } + [Theory] [InlineData("aaaa", SortField.Headword)] // FTS [InlineData("a", SortField.Headword)] // non-FTS @@ -97,9 +126,9 @@ static Entry[] CreateSortedEntrySet(string headword) // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = headword }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = lexeme }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 10 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = headword }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, - // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = lexeme }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, + // Prefix - SecondaryOrder: 20 (no leading token, so headword still starts with the lexeme) + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = headword }, MorphType = MorphTypeKind.Prefix/*, HomographNumber = 1*/ }, + // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = lexeme }, MorphType = MorphTypeKind.Prefix, HomographNumber = 2 }, ]; }