diff --git a/backend/FwLite/FwLiteShared.Tests/Services/MiniLcmJsInvokableMorphTypeFallbackTests.cs b/backend/FwLite/FwLiteShared.Tests/Services/MiniLcmJsInvokableMorphTypeFallbackTests.cs new file mode 100644 index 0000000000..21e2629160 --- /dev/null +++ b/backend/FwLite/FwLiteShared.Tests/Services/MiniLcmJsInvokableMorphTypeFallbackTests.cs @@ -0,0 +1,37 @@ +using FwLiteShared.Services; +using MiniLcm.Models; + +namespace FwLiteShared.Tests.Services; + +public class MiniLcmJsInvokableMorphTypeFallbackTests +{ + [Fact] + public void ApplyEmptyMorphTypeFallback_WhenEmpty_ReturnsCanonicalCopies() + { + var result = MiniLcmJsInvokable.ApplyEmptyMorphTypeFallback([]); + + result.Should().BeEquivalentTo(CanonicalMorphTypes.All.Values); + foreach (var returned in result) + { + ReferenceEquals(returned, CanonicalMorphTypes.All[returned.Kind]).Should().BeFalse( + "fallback must return copies so callers cannot mutate frozen canonicals"); + } + } + + [Fact] + public void ApplyEmptyMorphTypeFallback_WhenNonEmpty_PassesThrough() + { + var existing = new MorphType + { + Id = Guid.NewGuid(), + Kind = MorphTypeKind.Stem, + Name = new MultiString { { "en", "custom" } }, + Abbreviation = new MultiString { { "en", "c" } }, + Description = new RichMultiString(), + }; + + var result = MiniLcmJsInvokable.ApplyEmptyMorphTypeFallback([existing]); + + result.Should().ContainSingle().Which.Should().BeSameAs(existing); + } +} diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index ecba1bfae6..2be60072c2 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -100,9 +100,18 @@ public ValueTask GetCustomViews() } [JSInvokable] - public ValueTask GetMorphTypes() + public async ValueTask GetMorphTypes() { - return _wrappedApi.GetMorphTypes().ToArrayAsync(); + return ApplyEmptyMorphTypeFallback(await _wrappedApi.GetMorphTypes().ToArrayAsync()); + } + + /// + /// Temporary UI stopgap after #2350 removed migrate-time morph-type seeding for blank CRDT projects. + /// + internal static MorphType[] ApplyEmptyMorphTypeFallback(MorphType[] morphTypes) + { + if (morphTypes.Length != 0) return morphTypes; + return [.. CanonicalMorphTypes.All.Values.Select(mt => mt.Copy())]; } [JSInvokable] diff --git a/backend/FwLite/LcmCrdt.Tests/Data/DownloadProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/DownloadProjectTests.cs index d5f423ee83..02ba9b4ddd 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/DownloadProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/DownloadProjectTests.cs @@ -12,8 +12,8 @@ public class DownloadProjectTests : IAsyncLifetime public async Task InitializeAsync() { - await _helper.InitializeAsync(RegressionTestHelper.RegressionVersion.v2, withDataMigrations: true); - //add a change after migration which creates MorphTypes + await _helper.InitializeAsync(RegressionTestHelper.RegressionVersion.v2); + // Add a local change so sync has something beyond the scripted history to reconcile. await _helper.Services.GetRequiredService().CreateEntry(new Entry() { LexemeForm = {{"en", "test"}} diff --git a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs index 6238cd3157..7b31de32ed 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs @@ -33,9 +33,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(); + // Same open path as production: EF migrate + FTS regenerate-if-missing + refresh project data. + await projectsService.SetupProjectContext(_crdtProject); } public Task InitializeAsync() @@ -43,7 +42,7 @@ public Task InitializeAsync() return InitializeAsync(RegressionVersion.v2); } - public async Task InitializeAsync(RegressionVersion version, bool withDataMigrations = false) + public async Task InitializeAsync(RegressionVersion version) { var builder = Host.CreateEmptyApplicationBuilder(null); builder.Services.AddTestLcmCrdtClient(); @@ -51,16 +50,6 @@ public async Task InitializeAsync(RegressionVersion version, bool withDataMigrat var services = _host.Services; _asyncScope = services.CreateAsyncScope(); await InitDbFromScripts(version); - - // Data migrations are already on their way out. It doesn't really make sense for all tests to run them, - // which would change a bunch of verified project snapshots. - // When data migrations are removed (#2350), we should run this code unconditionally - // at the end of InitDbFromScripts (instead of the current db-migration and refresh-project-data approach) - // Because that's closer to the prod code. - if (withDataMigrations) - { - await Services.GetRequiredService().SetupProjectContext(_crdtProject); - } } public async Task DisposeAsync() diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index 0926263cd0..1d82dfa80f 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -1,6 +1,6 @@ using System.Diagnostics; +using LcmCrdt.Changes; using LcmCrdt.MediaServer; -using LcmCrdt.Objects; using Meziantou.Extensions.Logging.Xunit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -76,8 +76,12 @@ public async Task InitializeAsync(string projectName, Guid? projectId = null) //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, projectId ?? Guid.NewGuid(), null, Guid.NewGuid()); await CrdtProjectsService.InitProjectDb(_crdtDbContext, projectData); - // Also trigger "data migrations" that CreateProject runs await currentProjectService.SetupProjectContext(crdtProject); + // CreateProject no longer seeds morph types on migrate (#2350). This fixture bypasses + // CreateProjectFromTemplate, so seed them for tests that depend on them (sorting, + // homograph numbers, morph-token search, MorphTypeTestsBase). + await DataModel.AddChanges(projectData.ClientId, + [.. CanonicalMorphTypes.All.Values.Select(mt => new CreateMorphTypeChange(mt))]); if (_seedWs) { await Api.CreateWritingSystem(new WritingSystem() diff --git a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs index db04732475..56cd2023e5 100644 --- a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs @@ -1,17 +1,16 @@ -using LcmCrdt.Objects; +using LcmCrdt.Changes; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using SIL.Harmony; namespace LcmCrdt.Tests; public class MorphTypeSeedingTests { [Fact] - public async Task NewProject_HasAllCanonicalMorphTypes() + public async Task BlankNewProject_HasNoMorphTypesInDb() { - var code = "morph-type-seed-test"; + var code = "morph-type-blank-test"; var sqliteFile = $"{code}.sqlite"; if (File.Exists(sqliteFile)) File.Delete(sqliteFile); var builder = Host.CreateEmptyApplicationBuilder(null); @@ -21,90 +20,22 @@ public async Task NewProject_HasAllCanonicalMorphTypes() var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); var crdtProject = await crdtProjectsService.CreateProject(new( - Name: "MorphTypeSeedTest", + Name: "MorphTypeBlankTest", Code: code, Path: "")); var api = (CrdtMiniLcmApi)await scope.ServiceProvider.OpenCrdtProject(crdtProject); var morphTypes = await api.GetMorphTypes().ToArrayAsync(); - morphTypes.Should().BeEquivalentTo(CanonicalMorphTypes.All.Values); + morphTypes.Should().BeEmpty( + "blank CreateProject no longer seeds morph types on migrate (#2350)"); await using var dbContext = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); await dbContext.Database.EnsureDeletedAsync(); } [Fact] - public async Task ExistingProjectWithoutMorphTypes_GetsMorphTypesOnOpen() - { - 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(); - 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: code, - Path: "")); - - // 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 code = "morph-type-seed-idempotent"; - var sqliteFile = $"{code}.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: code, - Path: "")); - 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: 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"); - - await using var dbContext = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); - await dbContext.Database.EnsureDeletedAsync(); - } - } - - [Fact] - public async Task TemplatedProject_DoesNotGetRedundantSeedOnOpen() + public async Task TemplatedProject_HasCanonicalMorphTypesWithoutRedundantMigrateSeed() { var code = $"morph-type-seed-templated-{Guid.NewGuid():N}"; if (File.Exists($"{code}.sqlite")) File.Delete($"{code}.sqlite"); @@ -121,7 +52,6 @@ public async Task TemplatedProject_DoesNotGetRedundantSeedOnOpen() Role: UserProjectRole.Manager), vernacularWs: "fr"); - // Opening triggers MigrateDb; it must skip the seed because the template import already created the morph types. var api = (CrdtMiniLcmApi)await scope.ServiceProvider.OpenCrdtProject(crdtProject); var morphTypes = await api.GetMorphTypes().ToArrayAsync(); morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count); @@ -131,7 +61,7 @@ public async Task TemplatedProject_DoesNotGetRedundantSeedOnOpen() var morphTypeCreatingChanges = await dbContext.Database.SqlQuery( $""" SELECT COUNT(*) AS Value FROM ChangeEntities - WHERE json_extract(Change, '$."$type"') = {nameof(LcmCrdt.Changes.CreateMorphTypeChange)} + WHERE json_extract(Change, '$."$type"') = {nameof(CreateMorphTypeChange)} """).SingleAsync(); morphTypeCreatingChanges.Should().Be(CanonicalMorphTypes.All.Count, "the template import creates exactly the canonical morph types; MigrateDb must not add a redundant seed on open"); diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs index 6ebd147bc5..18aaee4a42 100644 --- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs @@ -21,7 +21,7 @@ public async Task CanCreateExampleProject() var crdtProjectsService = asyncScope.ServiceProvider.GetRequiredService(); var crdtProject = await crdtProjectsService.CreateExampleProject("ExampleProject"); - // Opening triggers MigrateDb; the template already carries morph types so no redundant seed runs. + // Opening triggers MigrateDb; the template already carries morph types. var api = (CrdtMiniLcmApi)await asyncScope.ServiceProvider.OpenCrdtProject(crdtProject); var morphTypes = await api.GetMorphTypes().ToArrayAsync(); diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index a7aa058dc7..9d65030391 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -258,10 +258,7 @@ public virtual async Task CreateProject(CreateProjectRequest reques await InitProjectDb(db, projectData); await currentProjectService.RefreshProjectData(); await (request.AfterCreate?.Invoke(serviceScope.ServiceProvider, crdtProject) ?? Task.CompletedTask); - // Ensure "data migrations" are executed on project creation (e.g. seeding morph types) - // These should happen AFTER the initial download, so they can be run conditionally based on - // the current state of the project. - // probably just remove this in #2350 + // Run EF migrate + FTS regenerate-if-missing (same path as opening an existing project). await currentProjectService.SetupProjectContext(crdtProject); } catch (Exception e) diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index 830cd213dc..44c0434a28 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -1,12 +1,10 @@ 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; @@ -108,16 +106,6 @@ 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. - var dataModel = services.GetRequiredService(); - var projectData = await dbContext.ProjectData.AsNoTracking().FirstAsync(); - // Remove in #2350 - if (!await dbContext.MorphTypes.AnyAsync()) - { - await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData); - } - if (EntrySearchServiceFactory is not null) { await using var ess = EntrySearchServiceFactory.CreateSearchService(dbContext); diff --git a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs deleted file mode 100644 index f2573f9a48..0000000000 --- a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs +++ /dev/null @@ -1,13 +0,0 @@ -using LcmCrdt.Changes; -using SIL.Harmony; - -namespace LcmCrdt.Objects; - -public static class PreDefinedData -{ - internal static async Task AddPredefinedMorphTypes(DataModel dataModel, ProjectData projectData) - { - await dataModel.AddChanges(projectData.ClientId, - [.. CanonicalMorphTypes.All.Values.Select(mt => new CreateMorphTypeChange(mt))]); - } -}