diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 4fd3f1447a..0234999f2d 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -552,6 +552,13 @@ await Cache.DoUsingNewOrCurrentUOW("Delete Complex Form Type", }); } + public Task CreateMorphType(MorphType morphType) + { + // Morph types are a fixed inventory in FieldWorks (see MoMorphTypeTags), seeded with every project. + // MiniLcm only ever needs to read or update them, never add new ones, so creation isn't supported here. + throw new NotSupportedException("Morph types cannot be created in FieldWorks data; they are a predefined inventory."); + } + public IAsyncEnumerable GetMorphTypes() { return diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Import/FullImportTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Import/FullImportTests.cs index 259f9fb1e5..be8191608e 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Import/FullImportTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Import/FullImportTests.cs @@ -43,12 +43,12 @@ public async Task DisposeAsync() } /// - /// Regression: Import creates a CRDT project with SeedNewProjectData: false. - /// Morph types must be seeded unconditionally so MorphTypeSync.Sync doesn't throw - /// when it encounters FwData morph types as "new". + /// Regression: Import creates a CRDT project with SeedNewProjectData: false, so morph types + /// are not pre-seeded. MorphTypeSync must create the source project's morph types during the + /// import instead of throwing when it encounters them as "new". /// [Fact] - public async Task Import_FullPath_SeedsMorphTypesBeforeImport() + public async Task Import_FullPath_CreatesMorphTypesDuringImport() { // Arrange: create an FwData project with one entry var projectName = "import-morph-types-" + Guid.NewGuid().ToString("N")[..8]; @@ -72,7 +72,7 @@ await fwDataApi.CreateEntry(new Entry var crdtApi = await Services.OpenCrdtProject((CrdtProject)crdtProject); var morphTypes = await crdtApi.GetMorphTypes().ToArrayAsync(); - morphTypes.Should().NotBeEmpty("morph types should be seeded during project creation"); + morphTypes.Should().NotBeEmpty("import should have created the source project's morph types"); var entries = await crdtApi.GetEntries().ToArrayAsync(); entries.Should().ContainSingle(e => e.LexemeForm["en"] == "test"); diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index 698e98bb04..a4351c5366 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -126,6 +126,12 @@ public Task DeleteComplexFormType(Guid id) return Task.CompletedTask; } + public Task CreateMorphType(MorphType morphType) + { + DryRunRecords.Add(new DryRunRecord(nameof(CreateMorphType), $"Create morph type {morphType.Kind} ({morphType.Id})")); + return Task.FromResult(morphType); + } + public async Task UpdateMorphType(Guid id, UpdateObjectInput update) { DryRunRecords.Add(new DryRunRecord(nameof(UpdateMorphType), $"Update morph type {id}")); diff --git a/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs b/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs index 3e562d8b3c..ee0fa10567 100644 --- a/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs +++ b/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs @@ -58,6 +58,10 @@ async Task IMiniLcmWriteApi.CreateComplexFormType(ComplexFormTy { return await HasCreated(complexFormType, _api.GetComplexFormTypes(), () => _api.CreateComplexFormType(complexFormType)); } + async Task IMiniLcmWriteApi.CreateMorphType(MorphType morphType) + { + return await HasCreated(morphType, _api.GetMorphTypes(), () => _api.CreateMorphType(morphType)); + } async Task IMiniLcmWriteApi.CreateSemanticDomain(SemanticDomain semanticDomain) { return await HasCreated(semanticDomain, _api.GetSemanticDomains(), () => _api.CreateSemanticDomain(semanticDomain)); diff --git a/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs b/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs index 30dcd664a9..15369552cc 100644 --- a/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs +++ b/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs @@ -71,7 +71,8 @@ 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 + // Sync morph types from the source: MorphTypeSync creates any canonical morph types the + // target doesn't already have and updates the rest (the target isn't pre-seeded on import). var importFromMorphTypes = await importFrom.GetMorphTypes().ToArrayAsync(); var existingMorphTypes = await importTo.GetMorphTypes().ToArrayAsync(); await MorphTypeSync.Sync(existingMorphTypes, importFromMorphTypes, importTo); diff --git a/backend/FwLite/LcmCrdt.Tests/Data/DownloadProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/DownloadProjectTests.cs index a0ee0bf4d2..484aa93045 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/DownloadProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/DownloadProjectTests.cs @@ -1,16 +1,25 @@ +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SIL.Harmony.Core; namespace LcmCrdt.Tests.Data; public class DownloadProjectTests : IAsyncLifetime { private readonly RegressionTestHelper _helper = new("DownloadProject"); - private readonly MiniLcmApiFixture _apiFixture = MiniLcmApiFixture.Create(false); + private static readonly Guid _projectId = new("B467051E-A492-4E5B-9C17-858D7797292C");//internal project Id of v2 project + // Download starts from an empty local project, so don't seed morph types — they should arrive via sync. + private readonly MiniLcmApiFixture _apiFixture = MiniLcmApiFixture.Create(false, _projectId, seedMorphTypes: false); public async Task InitializeAsync() { - await _helper.InitializeAsync(); + await _helper.InitializeAsync(RegressionTestHelper.RegressionVersion.v2); + //add a change after migration which creates MorphTypes + await _helper.Services.GetRequiredService().CreateEntry(new Entry() + { + LexemeForm = {{"en", "test"}} + }); await _apiFixture.InitializeAsync(); } @@ -24,6 +33,17 @@ public async Task DisposeAsync() public async Task CanCreateANewProjectViaSync() { var remoteModel = _helper.Services.GetRequiredService(); + var remoteDb = await _helper.Services.GetRequiredService>().CreateDbContextAsync(); + await _apiFixture.DataModel.SyncWith(remoteModel); + var localCommits = await _apiFixture.DbContext.Set().DefaultOrder().ToListAsync(); + var remoteCommits = await remoteDb.Set().DefaultOrder().ToListAsync(); + localCommits.Count.Should().Be(remoteCommits.Count); + for (var i = localCommits.Count - 1; i >= 0; i--) + { + var localCommit = localCommits[i]; + var remoteCommit = remoteCommits[i]; + localCommit.Should().BeEquivalentTo(remoteCommit, "commit index {0} should be the same", i); + } } } diff --git a/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs index b8c985e6c4..9f036c0805 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs @@ -10,9 +10,8 @@ namespace LcmCrdt.Tests.Data; [Collection("MigrationTests")] -public class MigrationTests : IAsyncLifetime +public class MigrationTests { - private readonly RegressionTestHelper _helper = new("MigrationTest"); private static readonly JsonSerializerOptions IndentedHarmonyJsonOptions = new(TestJsonOptions.Harmony()) { WriteIndented = true @@ -25,21 +24,12 @@ internal static void Init() VerifierSettings.OmitContentFromException(); } - public Task InitializeAsync() - { - return Task.CompletedTask; - } - - public async Task DisposeAsync() - { - await _helper.DisposeAsync(); - } - [Theory] [InlineData(RegressionTestHelper.RegressionVersion.v1)] [InlineData(RegressionTestHelper.RegressionVersion.v2)] public async Task GetEntries_WorksAfterMigrationFromScriptedDb(RegressionTestHelper.RegressionVersion regressionVersion) { + await using RegressionTestHelper _helper = new($"{nameof(GetEntries_WorksAfterMigrationFromScriptedDb)}-{regressionVersion}"); await _helper.InitializeAsync(regressionVersion); var api = _helper.Services.GetRequiredService(); var hasEntries = false; @@ -58,12 +48,13 @@ public async Task GetEntries_WorksAfterMigrationFromScriptedDb(RegressionTestHel [Trait("Category", "Verified")] public async Task VerifyAfterMigrationFromScriptedDb(RegressionTestHelper.RegressionVersion regressionVersion) { + await using RegressionTestHelper _helper = new($"{nameof(VerifyAfterMigrationFromScriptedDb)}-{regressionVersion}"); await _helper.InitializeAsync(regressionVersion); var api = _helper.Services.GetRequiredService(); var crdtConfig = _helper.Services.GetRequiredService>().Value; await using var dbContext = await _helper.Services.GetRequiredService().CreateDbContextAsync(); - var snapshots = await dbContext.Snapshots.AsNoTracking() + var allSnapshots = await dbContext.Snapshots.AsNoTracking() .OrderBy(s => s.TypeName) .ThenBy(s => s.EntityId) .ThenBy(c => c.Commit.HybridDateTime.DateTime) @@ -71,12 +62,19 @@ public async Task VerifyAfterMigrationFromScriptedDb(RegressionTestHelper.Regres .ThenBy(c => c.Commit.Id) .ToArrayAsync(); - var entityIdToTypeName = snapshots + var entityIdToTypeName = allSnapshots .GroupBy(s => s.EntityId) .ToDictionary(g => g.Key, g => g.First().TypeName); + + // Migration seeds canonical morph types with a random commit id (PreDefinedData.AddPredefinedMorphTypes), + // so their commit/snapshot ids aren't stable and can't be pinned here. Their seeding is verified exactly by + // MorphTypeSeedingTests and structurally by VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb. + const string morphTypeName = "MorphType"; + var snapshots = allSnapshots.Where(s => s.TypeName != morphTypeName).ToArray(); var changes = (await dbContext.Commits.AsNoTracking().Include(c => c.ChangeEntities) .ToArrayAsync()) .SelectMany(c => c.ChangeEntities.Select(changeEntity => new { ChangeEntity = changeEntity, c.HybridDateTime })) + .Where(c => entityIdToTypeName[c.ChangeEntity.EntityId] != morphTypeName) .OrderBy(s => entityIdToTypeName[s.ChangeEntity.EntityId]) .ThenBy(c => c.ChangeEntity.EntityId) .ThenBy(c => c.HybridDateTime.DateTime) @@ -110,6 +108,7 @@ await Task.WhenAll( [Trait("Category", "Verified")] public async Task VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb(RegressionTestHelper.RegressionVersion regressionVersion) { + await using RegressionTestHelper _helper = new($"{nameof(VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb)}-{regressionVersion}"); await _helper.InitializeAsync(regressionVersion); var api = _helper.Services.GetRequiredService(); var crdtConfig = _helper.Services.GetRequiredService>().Value; @@ -161,9 +160,8 @@ await api.GetSemanticDomains() await api.GetComplexFormTypes() .OrderBy(c => c.Id) .ToArrayAsync(), - await api.GetMorphTypes() - .OrderBy(m => m.Id) - .ToArrayAsync(), + // Morph types are excluded — migration seeds them with random ids; see VerifyAfterMigrationFromScriptedDb. + [], await api.GetWritingSystems()); } diff --git a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs index 82cf03e878..c61d1f651c 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs @@ -6,7 +6,7 @@ namespace LcmCrdt.Tests.Data; -public class RegressionTestHelper(string dbName): IAsyncLifetime +public class RegressionTestHelper(string dbName): IAsyncDisposable { private IHost _host = null!; private AsyncServiceScope _asyncScope; @@ -38,9 +38,8 @@ private async Task InitDbFromScripts(RegressionVersion version) //need to close the connection, otherwise the collations won't get created, they would normally be created on open or save, so we're closing so they get created when EF opens the connection. await dbConnection.CloseAsync(); - await lcmCrdtDbContext.Database.MigrateAsync(); - - await projectsService.RefreshProjectData(); + //setup again to trigger migrations + await projectsService.SetupProjectContext(crdtProject); } public Task InitializeAsync() @@ -58,7 +57,7 @@ public async Task InitializeAsync(RegressionVersion version) await InitDbFromScripts(version); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { await _asyncScope.DisposeAsync(); if (_host is IAsyncDisposable asyncDisposable) await asyncDisposable.DisposeAsync(); diff --git a/backend/FwLite/LcmCrdt.Tests/Data/VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb.v1.verified.json b/backend/FwLite/LcmCrdt.Tests/Data/VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb.v1.verified.json index 924dda4232..fa2fdae147 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb.v1.verified.json +++ b/backend/FwLite/LcmCrdt.Tests/Data/VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb.v1.verified.json @@ -114,16 +114,30 @@ }, { "Id": "Guid_12", - "TypeName": "PartOfSpeech", + "TypeName": "MorphType", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { - "$type": "PartOfSpeech", + "$type": "MorphType", "Id": "Guid_13", + "Kind": "DiscontiguousPhrase", "Name": { - "en": "Adverb" + "en": "discontiguous phrase" }, - "Predefined": true + "Abbreviation": { + "en": "dis phr" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 }, "Id": "Guid_13" }, @@ -135,160 +149,872 @@ }, { "Id": "Guid_15", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_16", + "Kind": "InfixingInterfix", + "Name": { + "en": "infixing interfix" + }, + "Abbreviation": { + "en": "ifxnfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "An infixing interfix is an infix that can occur between two roots or stems.", + "Ws": "en" + } + ] + } + }, + "Prefix": "-", + "Postfix": "-", + "SecondaryOrder": 0 + }, + "Id": "Guid_16" + }, + "References": [], + "EntityId": "Guid_16", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_17", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_18", + "Kind": "SuffixingInterfix", + "Name": { + "en": "suffixing interfix" + }, + "Abbreviation": { + "en": "sfxnfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A suffixing interfix is a suffix that can occur between two roots or stems.", + "Ws": "en" + } + ] + } + }, + "Prefix": "-", + "SecondaryOrder": 0 + }, + "Id": "Guid_18" + }, + "References": [], + "EntityId": "Guid_18", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_19", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_20", + "Kind": "Particle", + "Name": { + "en": "particle" + }, + "Abbreviation": { + "en": "part" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_20" + }, + "References": [], + "EntityId": "Guid_20", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_21", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_22", + "Kind": "Phrase", + "Name": { + "en": "phrase" + }, + "Abbreviation": { + "en": "phr" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A phrase is a syntactic structure that consists of more than one word but lacks the subject-predicate organization of a clause.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_22" + }, + "References": [], + "EntityId": "Guid_22", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_23", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_24", + "Kind": "PrefixingInterfix", + "Name": { + "en": "prefixing interfix" + }, + "Abbreviation": { + "en": "pfxnfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A prefixing interfix is a prefix that can occur between two roots or stems.", + "Ws": "en" + } + ] + } + }, + "Postfix": "-", + "SecondaryOrder": 0 + }, + "Id": "Guid_24" + }, + "References": [], + "EntityId": "Guid_24", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_25", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_26", + "Kind": "Clitic", + "Name": { + "en": "clitic" + }, + "Abbreviation": { + "en": "clit" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_26" + }, + "References": [], + "EntityId": "Guid_26", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_27", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_28", + "Kind": "Infix", + "Name": { + "en": "infix" + }, + "Abbreviation": { + "en": "ifx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "An infix is an affix that is inserted within a root or stem.", + "Ws": "en" + } + ] + } + }, + "Prefix": "-", + "Postfix": "-", + "SecondaryOrder": 40 + }, + "Id": "Guid_28" + }, + "References": [], + "EntityId": "Guid_28", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_29", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_30", + "Kind": "Prefix", + "Name": { + "en": "prefix" + }, + "Abbreviation": { + "en": "pfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A prefix is an affix that is joined before a root or stem.", + "Ws": "en" + } + ] + } + }, + "Postfix": "-", + "SecondaryOrder": 20 + }, + "Id": "Guid_30" + }, + "References": [], + "EntityId": "Guid_30", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_31", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_32", + "Kind": "Simulfix", + "Name": { + "en": "simulfix" + }, + "Abbreviation": { + "en": "smfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.)", + "Ws": "en" + } + ] + } + }, + "Prefix": "=", + "Postfix": "=", + "SecondaryOrder": 60 + }, + "Id": "Guid_32" + }, + "References": [], + "EntityId": "Guid_32", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_33", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_34", + "Kind": "Suffix", + "Name": { + "en": "suffix" + }, + "Abbreviation": { + "en": "sfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A suffix is an affix that is attached to the end of a root or stem.", + "Ws": "en" + } + ] + } + }, + "Prefix": "-", + "SecondaryOrder": 70 + }, + "Id": "Guid_34" + }, + "References": [], + "EntityId": "Guid_34", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_35", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_36", + "Kind": "Suprafix", + "Name": { + "en": "suprafix" + }, + "Abbreviation": { + "en": "spfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.)", + "Ws": "en" + } + ] + } + }, + "Prefix": "~", + "Postfix": "~", + "SecondaryOrder": 50 + }, + "Id": "Guid_36" + }, + "References": [], + "EntityId": "Guid_36", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_37", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_38", + "Kind": "Circumfix", + "Name": { + "en": "circumfix" + }, + "Abbreviation": { + "en": "cfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A circumfix is an affix made up of two separate parts which surround and attach to a root or stem.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_38" + }, + "References": [], + "EntityId": "Guid_38", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_39", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_40", + "Kind": "Enclitic", + "Name": { + "en": "enclitic" + }, + "Abbreviation": { + "en": "enclit" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "Prefix": "=", + "SecondaryOrder": 80 + }, + "Id": "Guid_40" + }, + "References": [], + "EntityId": "Guid_40", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_41", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_42", + "Kind": "Proclitic", + "Name": { + "en": "proclitic" + }, + "Abbreviation": { + "en": "proclit" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A proclitic is a clitic that precedes the word to which it is phonologically joined. Orthographically, it may attach to the following word.", + "Ws": "en" + } + ] + } + }, + "Postfix": "=", + "SecondaryOrder": 30 + }, + "Id": "Guid_42" + }, + "References": [], + "EntityId": "Guid_42", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_43", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_44", + "Kind": "BoundRoot", + "Name": { + "en": "bound root" + }, + "Abbreviation": { + "en": "bd root" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A bound root is a root which cannot occur as a separate word apart from any other morpheme.", + "Ws": "en" + } + ] + } + }, + "Prefix": "*", + "SecondaryOrder": 10 + }, + "Id": "Guid_44" + }, + "References": [], + "EntityId": "Guid_44", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_45", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_46", + "Kind": "Root", + "Name": { + "en": "root" + }, + "Abbreviation": { + "en": "ubd root" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_46" + }, + "References": [], + "EntityId": "Guid_46", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_47", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_48", + "Kind": "BoundStem", + "Name": { + "en": "bound stem" + }, + "Abbreviation": { + "en": "bd stem" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A bound stem is a stem which cannot occur as a separate word apart from any other morpheme.", + "Ws": "en" + } + ] + } + }, + "Prefix": "*", + "SecondaryOrder": 10 + }, + "Id": "Guid_48" + }, + "References": [], + "EntityId": "Guid_48", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_49", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_50", + "Kind": "Stem", + "Name": { + "en": "stem" + }, + "Abbreviation": { + "en": "ubd stem" + }, + "Description": { + "en": { + "Spans": [ + { + "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 ", + "Ws": "en" + }, + { + "Text": "man", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": "), or of two root morphemes (e.g. a 'compound' stem, as in ", + "Ws": "en" + }, + { + "Text": "blackbird", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": "), or of a root morpheme plus a derivational affix (i.e. a 'complex' stem, as in ", + "Ws": "en" + }, + { + "Text": "manly", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": ", ", + "Ws": "en" + }, + { + "Text": "unmanly", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": ", ", + "Ws": "en" + }, + { + "Text": "manliness", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": "). All have in common the notion that it is to the stem that inflectional affixes are attached.\" (Crystal, 1997:362)", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_50" + }, + "References": [], + "EntityId": "Guid_50", + "EntityIsDeleted": false, + "CommitId": "Guid_14", + "IsRoot": true + }, + { + "Id": "Guid_51", + "TypeName": "PartOfSpeech", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "PartOfSpeech", + "Id": "Guid_52", + "Name": { + "en": "Adverb" + }, + "Predefined": true + }, + "Id": "Guid_52" + }, + "References": [], + "EntityId": "Guid_52", + "EntityIsDeleted": false, + "CommitId": "Guid_53", + "IsRoot": true + }, + { + "Id": "Guid_54", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_16", + "Id": "Guid_55", "Name": { "en": "Universe, Creation" }, "Code": "1", "Predefined": true }, - "Id": "Guid_16" + "Id": "Guid_55" }, "References": [], - "EntityId": "Guid_16", + "EntityId": "Guid_55", "EntityIsDeleted": false, - "CommitId": "Guid_17", + "CommitId": "Guid_56", "IsRoot": true }, { - "Id": "Guid_18", + "Id": "Guid_57", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_19", + "Id": "Guid_58", "Name": { "en": "Sky" }, "Code": "1.1", "Predefined": true }, - "Id": "Guid_19" + "Id": "Guid_58" }, "References": [], - "EntityId": "Guid_19", + "EntityId": "Guid_58", "EntityIsDeleted": false, - "CommitId": "Guid_17", + "CommitId": "Guid_56", "IsRoot": true }, { - "Id": "Guid_20", + "Id": "Guid_59", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_21", + "Id": "Guid_60", "Name": { "en": "World" }, "Code": "1.2", "Predefined": true }, - "Id": "Guid_21" + "Id": "Guid_60" }, "References": [], - "EntityId": "Guid_21", + "EntityId": "Guid_60", "EntityIsDeleted": false, - "CommitId": "Guid_17", + "CommitId": "Guid_56", "IsRoot": true }, { - "Id": "Guid_22", + "Id": "Guid_61", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_23", + "Id": "Guid_62", "Name": { "en": "Person" }, "Code": "2", "Predefined": true }, - "Id": "Guid_23" + "Id": "Guid_62" }, "References": [], - "EntityId": "Guid_23", + "EntityId": "Guid_62", "EntityIsDeleted": false, - "CommitId": "Guid_17", + "CommitId": "Guid_56", "IsRoot": true }, { - "Id": "Guid_24", + "Id": "Guid_63", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_25", + "Id": "Guid_64", "Name": { "en": "Body" }, "Code": "2.1", "Predefined": true }, - "Id": "Guid_25" + "Id": "Guid_64" }, "References": [], - "EntityId": "Guid_25", + "EntityId": "Guid_64", "EntityIsDeleted": false, - "CommitId": "Guid_17", + "CommitId": "Guid_56", "IsRoot": true }, { - "Id": "Guid_26", + "Id": "Guid_65", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_27", + "Id": "Guid_66", "Name": { "en": "Head" }, "Code": "2.1.1", "Predefined": true }, - "Id": "Guid_27" + "Id": "Guid_66" }, "References": [], - "EntityId": "Guid_27", + "EntityId": "Guid_66", "EntityIsDeleted": false, - "CommitId": "Guid_17", + "CommitId": "Guid_56", "IsRoot": true }, { - "Id": "Guid_28", + "Id": "Guid_67", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_29", + "Id": "Guid_68", "Name": { "en": "Eye" }, "Code": "2.1.1.1", "Predefined": true }, - "Id": "Guid_29" + "Id": "Guid_68" }, "References": [], - "EntityId": "Guid_29", + "EntityId": "Guid_68", "EntityIsDeleted": false, - "CommitId": "Guid_17", + "CommitId": "Guid_56", "IsRoot": true }, { - "Id": "Guid_30", + "Id": "Guid_69", "TypeName": "Sense", "Entity": { "$type": "MiniLcmCrdtAdapter", @@ -324,14 +1050,14 @@ "IsRoot": true }, { - "Id": "Guid_31", + "Id": "Guid_70", "TypeName": "WritingSystem", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "WritingSystem", - "Id": "Guid_32", - "MaybeId": "Guid_32", + "Id": "Guid_71", + "MaybeId": "Guid_71", "WsId": "en", "IsAudio": false, "Name": "English", @@ -368,23 +1094,23 @@ ], "Order": 0 }, - "Id": "Guid_32" + "Id": "Guid_71" }, "References": [], - "EntityId": "Guid_32", + "EntityId": "Guid_71", "EntityIsDeleted": false, - "CommitId": "Guid_33", + "CommitId": "Guid_72", "IsRoot": true }, { - "Id": "Guid_34", + "Id": "Guid_73", "TypeName": "WritingSystem", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "WritingSystem", - "Id": "Guid_35", - "MaybeId": "Guid_35", + "Id": "Guid_74", + "MaybeId": "Guid_74", "WsId": "en", "IsAudio": false, "Name": "English", @@ -421,12 +1147,12 @@ ], "Order": 0 }, - "Id": "Guid_35" + "Id": "Guid_74" }, "References": [], - "EntityId": "Guid_35", + "EntityId": "Guid_74", "EntityIsDeleted": false, - "CommitId": "Guid_36", + "CommitId": "Guid_75", "IsRoot": true } ] \ No newline at end of file diff --git a/backend/FwLite/LcmCrdt.Tests/Data/VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb.v2.verified.json b/backend/FwLite/LcmCrdt.Tests/Data/VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb.v2.verified.json index 32340be1ab..a52945cd2e 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb.v2.verified.json +++ b/backend/FwLite/LcmCrdt.Tests/Data/VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb.v2.verified.json @@ -415,16 +415,30 @@ }, { "Id": "Guid_35", - "TypeName": "PartOfSpeech", + "TypeName": "MorphType", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { - "$type": "PartOfSpeech", + "$type": "MorphType", "Id": "Guid_36", + "Kind": "DiscontiguousPhrase", "Name": { - "en": "Adverb" + "en": "discontiguous phrase" }, - "Predefined": true + "Abbreviation": { + "en": "dis phr" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 }, "Id": "Guid_36" }, @@ -436,160 +450,872 @@ }, { "Id": "Guid_38", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_39", + "Kind": "InfixingInterfix", + "Name": { + "en": "infixing interfix" + }, + "Abbreviation": { + "en": "ifxnfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "An infixing interfix is an infix that can occur between two roots or stems.", + "Ws": "en" + } + ] + } + }, + "Prefix": "-", + "Postfix": "-", + "SecondaryOrder": 0 + }, + "Id": "Guid_39" + }, + "References": [], + "EntityId": "Guid_39", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_40", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_41", + "Kind": "SuffixingInterfix", + "Name": { + "en": "suffixing interfix" + }, + "Abbreviation": { + "en": "sfxnfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A suffixing interfix is a suffix that can occur between two roots or stems.", + "Ws": "en" + } + ] + } + }, + "Prefix": "-", + "SecondaryOrder": 0 + }, + "Id": "Guid_41" + }, + "References": [], + "EntityId": "Guid_41", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_42", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_43", + "Kind": "Particle", + "Name": { + "en": "particle" + }, + "Abbreviation": { + "en": "part" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_43" + }, + "References": [], + "EntityId": "Guid_43", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_44", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_45", + "Kind": "Phrase", + "Name": { + "en": "phrase" + }, + "Abbreviation": { + "en": "phr" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A phrase is a syntactic structure that consists of more than one word but lacks the subject-predicate organization of a clause.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_45" + }, + "References": [], + "EntityId": "Guid_45", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_46", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_47", + "Kind": "PrefixingInterfix", + "Name": { + "en": "prefixing interfix" + }, + "Abbreviation": { + "en": "pfxnfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A prefixing interfix is a prefix that can occur between two roots or stems.", + "Ws": "en" + } + ] + } + }, + "Postfix": "-", + "SecondaryOrder": 0 + }, + "Id": "Guid_47" + }, + "References": [], + "EntityId": "Guid_47", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_48", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_49", + "Kind": "Clitic", + "Name": { + "en": "clitic" + }, + "Abbreviation": { + "en": "clit" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_49" + }, + "References": [], + "EntityId": "Guid_49", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_50", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_51", + "Kind": "Infix", + "Name": { + "en": "infix" + }, + "Abbreviation": { + "en": "ifx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "An infix is an affix that is inserted within a root or stem.", + "Ws": "en" + } + ] + } + }, + "Prefix": "-", + "Postfix": "-", + "SecondaryOrder": 40 + }, + "Id": "Guid_51" + }, + "References": [], + "EntityId": "Guid_51", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_52", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_53", + "Kind": "Prefix", + "Name": { + "en": "prefix" + }, + "Abbreviation": { + "en": "pfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A prefix is an affix that is joined before a root or stem.", + "Ws": "en" + } + ] + } + }, + "Postfix": "-", + "SecondaryOrder": 20 + }, + "Id": "Guid_53" + }, + "References": [], + "EntityId": "Guid_53", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_54", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_55", + "Kind": "Simulfix", + "Name": { + "en": "simulfix" + }, + "Abbreviation": { + "en": "smfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.)", + "Ws": "en" + } + ] + } + }, + "Prefix": "=", + "Postfix": "=", + "SecondaryOrder": 60 + }, + "Id": "Guid_55" + }, + "References": [], + "EntityId": "Guid_55", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_56", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_57", + "Kind": "Suffix", + "Name": { + "en": "suffix" + }, + "Abbreviation": { + "en": "sfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A suffix is an affix that is attached to the end of a root or stem.", + "Ws": "en" + } + ] + } + }, + "Prefix": "-", + "SecondaryOrder": 70 + }, + "Id": "Guid_57" + }, + "References": [], + "EntityId": "Guid_57", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_58", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_59", + "Kind": "Suprafix", + "Name": { + "en": "suprafix" + }, + "Abbreviation": { + "en": "spfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.)", + "Ws": "en" + } + ] + } + }, + "Prefix": "~", + "Postfix": "~", + "SecondaryOrder": 50 + }, + "Id": "Guid_59" + }, + "References": [], + "EntityId": "Guid_59", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_60", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_61", + "Kind": "Circumfix", + "Name": { + "en": "circumfix" + }, + "Abbreviation": { + "en": "cfx" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A circumfix is an affix made up of two separate parts which surround and attach to a root or stem.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_61" + }, + "References": [], + "EntityId": "Guid_61", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_62", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_63", + "Kind": "Enclitic", + "Name": { + "en": "enclitic" + }, + "Abbreviation": { + "en": "enclit" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "Prefix": "=", + "SecondaryOrder": 80 + }, + "Id": "Guid_63" + }, + "References": [], + "EntityId": "Guid_63", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_64", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_65", + "Kind": "Proclitic", + "Name": { + "en": "proclitic" + }, + "Abbreviation": { + "en": "proclit" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A proclitic is a clitic that precedes the word to which it is phonologically joined. Orthographically, it may attach to the following word.", + "Ws": "en" + } + ] + } + }, + "Postfix": "=", + "SecondaryOrder": 30 + }, + "Id": "Guid_65" + }, + "References": [], + "EntityId": "Guid_65", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_66", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_67", + "Kind": "BoundRoot", + "Name": { + "en": "bound root" + }, + "Abbreviation": { + "en": "bd root" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A bound root is a root which cannot occur as a separate word apart from any other morpheme.", + "Ws": "en" + } + ] + } + }, + "Prefix": "*", + "SecondaryOrder": 10 + }, + "Id": "Guid_67" + }, + "References": [], + "EntityId": "Guid_67", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_68", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_69", + "Kind": "Root", + "Name": { + "en": "root" + }, + "Abbreviation": { + "en": "ubd root" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "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.", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_69" + }, + "References": [], + "EntityId": "Guid_69", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_70", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_71", + "Kind": "BoundStem", + "Name": { + "en": "bound stem" + }, + "Abbreviation": { + "en": "bd stem" + }, + "Description": { + "en": { + "Spans": [ + { + "Text": "A bound stem is a stem which cannot occur as a separate word apart from any other morpheme.", + "Ws": "en" + } + ] + } + }, + "Prefix": "*", + "SecondaryOrder": 10 + }, + "Id": "Guid_71" + }, + "References": [], + "EntityId": "Guid_71", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_72", + "TypeName": "MorphType", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "MorphType", + "Id": "Guid_73", + "Kind": "Stem", + "Name": { + "en": "stem" + }, + "Abbreviation": { + "en": "ubd stem" + }, + "Description": { + "en": { + "Spans": [ + { + "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 ", + "Ws": "en" + }, + { + "Text": "man", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": "), or of two root morphemes (e.g. a 'compound' stem, as in ", + "Ws": "en" + }, + { + "Text": "blackbird", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": "), or of a root morpheme plus a derivational affix (i.e. a 'complex' stem, as in ", + "Ws": "en" + }, + { + "Text": "manly", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": ", ", + "Ws": "en" + }, + { + "Text": "unmanly", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": ", ", + "Ws": "en" + }, + { + "Text": "manliness", + "Ws": "en", + "NamedStyle": "Emphasized Text" + }, + { + "Text": "). All have in common the notion that it is to the stem that inflectional affixes are attached.\" (Crystal, 1997:362)", + "Ws": "en" + } + ] + } + }, + "SecondaryOrder": 0 + }, + "Id": "Guid_73" + }, + "References": [], + "EntityId": "Guid_73", + "EntityIsDeleted": false, + "CommitId": "Guid_37", + "IsRoot": true + }, + { + "Id": "Guid_74", + "TypeName": "PartOfSpeech", + "Entity": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "PartOfSpeech", + "Id": "Guid_75", + "Name": { + "en": "Adverb" + }, + "Predefined": true + }, + "Id": "Guid_75" + }, + "References": [], + "EntityId": "Guid_75", + "EntityIsDeleted": false, + "CommitId": "Guid_76", + "IsRoot": true + }, + { + "Id": "Guid_77", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_39", + "Id": "Guid_78", "Name": { "en": "Person" }, "Code": "2", "Predefined": true }, - "Id": "Guid_39" + "Id": "Guid_78" }, "References": [], - "EntityId": "Guid_39", + "EntityId": "Guid_78", "EntityIsDeleted": false, - "CommitId": "Guid_40", + "CommitId": "Guid_79", "IsRoot": true }, { - "Id": "Guid_41", + "Id": "Guid_80", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_42", + "Id": "Guid_81", "Name": { "en": "Body" }, "Code": "2.1", "Predefined": false }, - "Id": "Guid_42" + "Id": "Guid_81" }, "References": [], - "EntityId": "Guid_42", + "EntityId": "Guid_81", "EntityIsDeleted": false, - "CommitId": "Guid_40", + "CommitId": "Guid_79", "IsRoot": true }, { - "Id": "Guid_43", + "Id": "Guid_82", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_44", + "Id": "Guid_83", "Name": { "en": "Head" }, "Code": "2.1.1", "Predefined": false }, - "Id": "Guid_44" + "Id": "Guid_83" }, "References": [], - "EntityId": "Guid_44", + "EntityId": "Guid_83", "EntityIsDeleted": false, - "CommitId": "Guid_40", + "CommitId": "Guid_79", "IsRoot": true }, { - "Id": "Guid_45", + "Id": "Guid_84", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_46", + "Id": "Guid_85", "Name": { "en": "Eye" }, "Code": "2.1.1.1", "Predefined": false }, - "Id": "Guid_46" + "Id": "Guid_85" }, "References": [], - "EntityId": "Guid_46", + "EntityId": "Guid_85", "EntityIsDeleted": false, - "CommitId": "Guid_40", + "CommitId": "Guid_79", "IsRoot": true }, { - "Id": "Guid_47", + "Id": "Guid_86", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_48", + "Id": "Guid_87", "Name": { "en": "Universe, Creation" }, "Code": "1", "Predefined": true }, - "Id": "Guid_48" + "Id": "Guid_87" }, "References": [], - "EntityId": "Guid_48", + "EntityId": "Guid_87", "EntityIsDeleted": false, - "CommitId": "Guid_40", + "CommitId": "Guid_79", "IsRoot": true }, { - "Id": "Guid_49", + "Id": "Guid_88", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_50", + "Id": "Guid_89", "Name": { "en": "Sky" }, "Code": "1.1", "Predefined": true }, - "Id": "Guid_50" + "Id": "Guid_89" }, "References": [], - "EntityId": "Guid_50", + "EntityId": "Guid_89", "EntityIsDeleted": false, - "CommitId": "Guid_40", + "CommitId": "Guid_79", "IsRoot": true }, { - "Id": "Guid_51", + "Id": "Guid_90", "TypeName": "SemanticDomain", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "SemanticDomain", - "Id": "Guid_52", + "Id": "Guid_91", "Name": { "en": "World" }, "Code": "1.2", "Predefined": true }, - "Id": "Guid_52" + "Id": "Guid_91" }, "References": [], - "EntityId": "Guid_52", + "EntityId": "Guid_91", "EntityIsDeleted": false, - "CommitId": "Guid_40", + "CommitId": "Guid_79", "IsRoot": true }, { - "Id": "Guid_53", + "Id": "Guid_92", "TypeName": "Sense", "Entity": { "$type": "MiniLcmCrdtAdapter", @@ -625,7 +1351,7 @@ "IsRoot": true }, { - "Id": "Guid_54", + "Id": "Guid_93", "TypeName": "Sense", "Entity": { "$type": "MiniLcmCrdtAdapter", @@ -661,7 +1387,7 @@ "IsRoot": true }, { - "Id": "Guid_55", + "Id": "Guid_94", "TypeName": "Sense", "Entity": { "$type": "MiniLcmCrdtAdapter", @@ -693,16 +1419,16 @@ "fr": "fr" }, "PartOfSpeech": { - "Id": "Guid_36", + "Id": "Guid_75", "Name": { "en": "Adverb" }, "Predefined": true }, - "PartOfSpeechId": "Guid_36", + "PartOfSpeechId": "Guid_75", "SemanticDomains": [ { - "Id": "Guid_48", + "Id": "Guid_87", "Name": { "en": "Universe, Creation" }, @@ -716,16 +1442,16 @@ }, "References": [ "Guid_4", - "Guid_36", - "Guid_48" + "Guid_75", + "Guid_87" ], "EntityId": "Guid_24", "EntityIsDeleted": false, - "CommitId": "Guid_56", + "CommitId": "Guid_95", "IsRoot": false }, { - "Id": "Guid_57", + "Id": "Guid_96", "TypeName": "Sense", "Entity": { "$type": "MiniLcmCrdtAdapter", @@ -761,14 +1487,14 @@ "IsRoot": true }, { - "Id": "Guid_58", + "Id": "Guid_97", "TypeName": "WritingSystem", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "WritingSystem", - "Id": "Guid_59", - "MaybeId": "Guid_59", + "Id": "Guid_98", + "MaybeId": "Guid_98", "WsId": "en", "IsAudio": false, "Name": "English", @@ -805,23 +1531,23 @@ ], "Order": 1 }, - "Id": "Guid_59" + "Id": "Guid_98" }, "References": [], - "EntityId": "Guid_59", + "EntityId": "Guid_98", "EntityIsDeleted": false, - "CommitId": "Guid_60", + "CommitId": "Guid_99", "IsRoot": true }, { - "Id": "Guid_61", + "Id": "Guid_100", "TypeName": "WritingSystem", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "WritingSystem", - "Id": "Guid_62", - "MaybeId": "Guid_62", + "Id": "Guid_101", + "MaybeId": "Guid_101", "WsId": "de", "IsAudio": false, "Name": "German", @@ -858,23 +1584,23 @@ ], "Order": 1 }, - "Id": "Guid_62" + "Id": "Guid_101" }, "References": [], - "EntityId": "Guid_62", + "EntityId": "Guid_101", "EntityIsDeleted": false, - "CommitId": "Guid_63", + "CommitId": "Guid_102", "IsRoot": true }, { - "Id": "Guid_64", + "Id": "Guid_103", "TypeName": "WritingSystem", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "WritingSystem", - "Id": "Guid_65", - "MaybeId": "Guid_65", + "Id": "Guid_104", + "MaybeId": "Guid_104", "WsId": "en-Zxxx-x-audio", "IsAudio": true, "Name": "English (A)", @@ -911,23 +1637,23 @@ ], "Order": 3 }, - "Id": "Guid_65" + "Id": "Guid_104" }, "References": [], - "EntityId": "Guid_65", + "EntityId": "Guid_104", "EntityIsDeleted": false, - "CommitId": "Guid_66", + "CommitId": "Guid_105", "IsRoot": true }, { - "Id": "Guid_67", + "Id": "Guid_106", "TypeName": "WritingSystem", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "WritingSystem", - "Id": "Guid_68", - "MaybeId": "Guid_68", + "Id": "Guid_107", + "MaybeId": "Guid_107", "WsId": "en", "IsAudio": false, "Name": "English", @@ -964,23 +1690,23 @@ ], "Order": 2 }, - "Id": "Guid_68" + "Id": "Guid_107" }, "References": [], - "EntityId": "Guid_68", + "EntityId": "Guid_107", "EntityIsDeleted": false, - "CommitId": "Guid_69", + "CommitId": "Guid_108", "IsRoot": true }, { - "Id": "Guid_70", + "Id": "Guid_109", "TypeName": "WritingSystem", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "WritingSystem", - "Id": "Guid_71", - "MaybeId": "Guid_71", + "Id": "Guid_110", + "MaybeId": "Guid_110", "WsId": "en-Zxxx-x-audio", "IsAudio": true, "Name": "English (A)", @@ -1017,23 +1743,23 @@ ], "Order": 3 }, - "Id": "Guid_71" + "Id": "Guid_110" }, "References": [], - "EntityId": "Guid_71", + "EntityId": "Guid_110", "EntityIsDeleted": false, - "CommitId": "Guid_72", + "CommitId": "Guid_111", "IsRoot": true }, { - "Id": "Guid_73", + "Id": "Guid_112", "TypeName": "WritingSystem", "Entity": { "$type": "MiniLcmCrdtAdapter", "Obj": { "$type": "WritingSystem", - "Id": "Guid_74", - "MaybeId": "Guid_74", + "Id": "Guid_113", + "MaybeId": "Guid_113", "WsId": "fr", "IsAudio": false, "Name": "French", @@ -1070,12 +1796,12 @@ ], "Order": 2 }, - "Id": "Guid_74" + "Id": "Guid_113" }, "References": [], - "EntityId": "Guid_74", + "EntityId": "Guid_113", "EntityIsDeleted": false, - "CommitId": "Guid_75", + "CommitId": "Guid_114", "IsRoot": true } ] \ No newline at end of file diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index 3218031489..e7dfba7ec9 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -14,6 +14,8 @@ namespace LcmCrdt.Tests; public class MiniLcmApiFixture : IAsyncLifetime, IAsyncDisposable { private readonly bool _seedWs = true; + private readonly bool _seedMorphTypes = true; + private readonly Guid? _projectId; private AsyncServiceScope _services; private LcmCrdtDbContext? _crdtDbContext; public CrdtMiniLcmApi Api => (CrdtMiniLcmApi)_services.ServiceProvider.GetRequiredService(); @@ -31,22 +33,24 @@ public MiniLcmApiFixture() { } - public static MiniLcmApiFixture Create(bool seedWs = true) + public static MiniLcmApiFixture Create(bool seedWs = true, Guid? projectId = null, bool seedMorphTypes = true) { - return new MiniLcmApiFixture(seedWs); + return new MiniLcmApiFixture(seedWs, projectId, seedMorphTypes); } - private MiniLcmApiFixture(bool seedWs = true) + private MiniLcmApiFixture(bool seedWs = true, Guid? projectId = null, bool seedMorphTypes = true) { _seedWs = seedWs; + _projectId = projectId; + _seedMorphTypes = seedMorphTypes; } public async Task InitializeAsync() { - await InitializeAsync("sena-3"); + await InitializeAsync("sena-3", _projectId); } - public async Task InitializeAsync(string projectName) + public async Task InitializeAsync(string projectName, Guid? projectId = null) { var db = $"file:{Guid.NewGuid():N}?mode=memory&cache=shared"; if (Debugger.IsAttached) @@ -72,12 +76,17 @@ 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()); + var projectData = new ProjectData("Sena 3", projectName, projectId ?? Guid.NewGuid(), null, Guid.NewGuid()); await CrdtProjectsService.InitProjectDb(_crdtDbContext, projectData); await currentProjectService.RefreshProjectData(); - // CreateProject would also seed morph types — so we need to do it manually here - await PreDefinedData.AddPredefinedMorphTypes(_services.ServiceProvider.GetRequiredService(), projectData); + // CreateProject seeds morph types via SeedNewProjectData/migration; this fixture bypasses + // CreateProject (see comment above), so seed them directly for tests that depend on them + // (sorting, homograph numbers, morph-token search). + if (_seedMorphTypes) + { + await PreDefinedData.AddPredefinedMorphTypes(DataModel, projectData, false); + } if (_seedWs) { await Api.CreateWritingSystem(new WritingSystem() diff --git a/backend/FwLite/LcmCrdt/Changes/CreateMorphTypeChange.cs b/backend/FwLite/LcmCrdt/Changes/CreateMorphTypeChange.cs index 5661cb2de6..e3f8c32794 100644 --- a/backend/FwLite/LcmCrdt/Changes/CreateMorphTypeChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/CreateMorphTypeChange.cs @@ -8,7 +8,7 @@ namespace LcmCrdt.Changes; -public class CreateMorphTypeChange : CreateChange, ISelfNamedType +public class CreateMorphTypeChange : Change, ISelfNamedType { [SetsRequiredMembers] public CreateMorphTypeChange(MorphType morphType) : base(morphType.Id) @@ -58,4 +58,10 @@ public override async ValueTask NewEntity(Commit commit, IChangeConte DeletedAt = alreadyExists ? commit.DateTime : null }; } + + public override ValueTask ApplyChange(MorphType entity, IChangeContext context) + { + //don't do anything here, CreateMorphTypeChange is used in a migration, and a change to create the same morph type may happen multiple times + return ValueTask.CompletedTask; + } } diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 5a4053ec35..58e963df61 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -373,6 +373,12 @@ public async IAsyncEnumerable GetMorphTypes() return await repo.MorphTypes.SingleOrDefaultAsync(m => m.Kind == kind); } + public async Task CreateMorphType(MorphType morphType) + { + await AddChange(new CreateMorphTypeChange(morphType)); + return await GetMorphType(morphType.Id) ?? throw NotFoundException.ForType(morphType.Id); + } + public async Task UpdateMorphType(Guid id, UpdateObjectInput update) { await AddChange(new JsonPatchChange(id, update.Patch)); diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index f6ab9cf490..01e2d81acd 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -166,10 +166,7 @@ public virtual async Task CreateProject(CreateProjectRequest reques crdtProject.Data = projectData; await InitProjectDb(db, projectData); await currentProjectService.RefreshProjectData(); - // Morph types are predefined system data that must always exist — seed them - // unconditionally so they're available before AfterCreate (e.g. import) runs. var dataModel = serviceScope.ServiceProvider.GetRequiredService(); - await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData); if (request.SeedNewProjectData) await SeedSystemData(dataModel, projectData); await (request.AfterCreate?.Invoke(serviceScope.ServiceProvider, crdtProject) ?? Task.CompletedTask); @@ -245,7 +242,7 @@ internal static async Task InitProjectDb(LcmCrdtDbContext db, ProjectData data) internal static async Task SeedSystemData(DataModel dataModel, ProjectData projectData) { - // Note: AddPredefinedMorphTypes is seeded unconditionally in CreateProject, not here. + await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData, false); await PreDefinedData.AddPredefinedComplexFormTypes(dataModel, projectData); await PreDefinedData.AddPredefinedPartsOfSpeech(dataModel, projectData); await PreDefinedData.AddPredefinedSemanticDomains(dataModel, projectData); diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index 885e0856fe..ba51f155d0 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -112,7 +112,10 @@ async Task Execute() // Must happen BEFORE FTS regeneration so headwords include morph-type tokens. var dataModel = services.GetRequiredService(); var projectData = await dbContext.ProjectData.AsNoTracking().FirstAsync(); - await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData); + if (!await dbContext.MorphTypes.AnyAsync()) + { + await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData, true); + } if (EntrySearchServiceFactory is not null) { diff --git a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs index f319de8921..5c1454530c 100644 --- a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs +++ b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs @@ -104,10 +104,10 @@ await dataModel.AddChanges(projectData.ClientId, CustomViewsSeedCommitId(projectData.Id)); } - internal static async Task AddPredefinedMorphTypes(DataModel dataModel, ProjectData projectData) + internal static async Task AddPredefinedMorphTypes(DataModel dataModel, ProjectData projectData, bool isMigration) { await dataModel.AddChanges(projectData.ClientId, [.. CanonicalMorphTypes.All.Values.Select(mt => new CreateMorphTypeChange(mt))], - MorphTypesSeedCommitId(projectData.Id)); + isMigration ? Guid.NewGuid() : MorphTypesSeedCommitId(projectData.Id)); } } diff --git a/backend/FwLite/LcmDebugger/FakeSyncSource.cs b/backend/FwLite/LcmDebugger/FakeSyncSource.cs index 39574212d9..aa697ef6b8 100644 --- a/backend/FwLite/LcmDebugger/FakeSyncSource.cs +++ b/backend/FwLite/LcmDebugger/FakeSyncSource.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using LcmCrdt; using SIL.Harmony; using SIL.Harmony.Changes; using SIL.Harmony.Core; @@ -39,8 +40,20 @@ public static FakeSyncSource FromSingleChangeJson( public static FakeSyncSource FromJsonFile(string path, JsonSerializerOptions? options = null) { + if (options is null) + { + var config = new CrdtConfig(); + LcmCrdtKernel.ConfigureCrdt(config); + options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = config.MakeLcmCrdtExternalJsonTypeResolver(), + }; + } + var changes = JsonSerializer.Deserialize>(File.OpenRead(path), options); ArgumentNullException.ThrowIfNull(changes); + ArgumentNullException.ThrowIfNull(changes.MissingFromClient); + ArgumentNullException.ThrowIfNull(changes.ServerSyncState); return new FakeSyncSource(changes.MissingFromClient, changes.ServerSyncState); } diff --git a/backend/FwLite/LcmDebugger/Utils.cs b/backend/FwLite/LcmDebugger/Utils.cs index 7ed135a3fa..3430e9244e 100644 --- a/backend/FwLite/LcmDebugger/Utils.cs +++ b/backend/FwLite/LcmDebugger/Utils.cs @@ -5,6 +5,7 @@ using FwLiteProjectSync; using LcmCrdt; using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony; using SIL.LCModel; namespace LcmDebugger; @@ -46,6 +47,15 @@ public static async Task PrintAllEntries(this IServiceProvider services, string } } + public static async Task NewProjectFromSyncable(this IServiceProvider services, ISyncable syncable, Guid? projectId = null) + { + var crdtProjectsService = services.GetRequiredService(); + var crdtProject = await crdtProjectsService.CreateProject(new CrdtProjectsService.CreateProjectRequest("test-project", $"test-{Guid.NewGuid().ToString().Split('-')[0]}", projectId)); + var crdtMiniLcmApi = (CrdtMiniLcmApi)await crdtProjectsService.OpenProject(crdtProject, services); + await services.GetRequiredService().SyncWith(syncable); + return crdtMiniLcmApi; + } + public static async Task OpenDownloadedProject(this IServiceProvider services, string relativePath, bool openCopy = false, string? downloadsRoot = null) { // Default to a path relative to the executing assembly, pointing to the deployment/_downloads folder diff --git a/backend/FwLite/MiniLcm.Tests/MorphTypeSyncTests.cs b/backend/FwLite/MiniLcm.Tests/MorphTypeSyncTests.cs index d4b7df19ce..4a1c830daf 100644 --- a/backend/FwLite/MiniLcm.Tests/MorphTypeSyncTests.cs +++ b/backend/FwLite/MiniLcm.Tests/MorphTypeSyncTests.cs @@ -1,11 +1,12 @@ using MiniLcm.SyncHelpers; +using Moq; namespace MiniLcm.Tests; public class MorphTypeSyncTests { [Fact] - public async Task Sync_ThrowsOnAdd_WhenAfterContainsExtraMorphType() + public async Task Sync_CreatesMorphType_WhenAfterContainsExtraMorphType() { var before = CanonicalMorphTypes.All.Values.ToArray(); var extraMorphType = new MorphType @@ -15,11 +16,13 @@ public async Task Sync_ThrowsOnAdd_WhenAfterContainsExtraMorphType() Name = new MultiString { { "en", "bogus" } }, }; var after = before.Append(extraMorphType).ToArray(); + var api = new Mock(); + api.Setup(a => a.CreateMorphType(It.IsAny())).ReturnsAsync((MorphType mt) => mt); - var act = () => MorphTypeSync.Sync(before, after, null!); + var changeCount = await MorphTypeSync.Sync(before, after, api.Object); - await act.Should().ThrowAsync() - .WithMessage("*cannot be created*"); + changeCount.Should().Be(1); + api.Verify(a => a.CreateMorphType(It.Is(mt => mt.Id == extraMorphType.Id)), Times.Once); } [Fact] diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index 6d59b3e10c..7bcf31255a 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -45,6 +45,7 @@ Task UpdateWritingSystem(WritingSystemId id, #endregion #region MorphType + Task CreateMorphType(MorphType morphType); Task UpdateMorphType(Guid id, UpdateObjectInput update); Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api = null); #endregion diff --git a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs index aa9f030172..bf2d901f8f 100644 --- a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs +++ b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs @@ -209,6 +209,11 @@ private static ComplexFormType NormalizeComplexFormType(ComplexFormType cft) #region MorphType + public async Task CreateMorphType(MorphType morphType) + { + return await _api.CreateMorphType(NormalizeMorphType(morphType)); + } + public Task UpdateMorphType(Guid id, UpdateObjectInput update) { return _api.UpdateMorphType(id, NormalizePatch(update)); diff --git a/backend/FwLite/MiniLcm/SyncHelpers/MorphTypeSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/MorphTypeSync.cs index 2a89dcadc8..9da1655d40 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/MorphTypeSync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/MorphTypeSync.cs @@ -56,10 +56,10 @@ public static async Task Sync(MorphType before, private class MorphTypeDiffApi(IMiniLcmApi api) : ObjectWithIdCollectionDiffApi { - public override Task Add(MorphType currentMorphType) + public override async Task Add(MorphType currentMorphType) { - throw new InvalidOperationException( - $"MorphTypes are predefined and cannot be created. Unexpected morph type: {currentMorphType.Kind} ({currentMorphType.Id}). This indicates a data inconsistency."); + await api.CreateMorphType(currentMorphType); + return 1; } public override Task Remove(MorphType beforeMorphType)