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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 11 additions & 2 deletions backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,18 @@ public ValueTask<CustomView[]> GetCustomViews()
}

[JSInvokable]
public ValueTask<MorphType[]> GetMorphTypes()
public async ValueTask<MorphType[]> GetMorphTypes()
{
return _wrappedApi.GetMorphTypes().ToArrayAsync();
return ApplyEmptyMorphTypeFallback(await _wrappedApi.GetMorphTypes().ToArrayAsync());
}

/// <summary>
/// Temporary UI stopgap after #2350 removed migrate-time morph-type seeding for blank CRDT projects.
/// </summary>
internal static MorphType[] ApplyEmptyMorphTypeFallback(MorphType[] morphTypes)
{
if (morphTypes.Length != 0) return morphTypes;
return [.. CanonicalMorphTypes.All.Values.Select(mt => mt.Copy())];
}

[JSInvokable]
Expand Down
4 changes: 2 additions & 2 deletions backend/FwLite/LcmCrdt.Tests/Data/DownloadProjectTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IMiniLcmApi>().CreateEntry(new Entry()
{
LexemeForm = {{"en", "test"}}
Expand Down
17 changes: 3 additions & 14 deletions backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,34 +33,23 @@ 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()
{
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();
_host = builder.Build();
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<CurrentProjectService>().SetupProjectContext(_crdtProject);
}
}

public async Task DisposeAsync()
Expand Down
8 changes: 6 additions & 2 deletions backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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()
Expand Down
86 changes: 8 additions & 78 deletions backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -21,90 +20,22 @@ public async Task NewProject_HasAllCanonicalMorphTypes()

var crdtProjectsService = scope.ServiceProvider.GetRequiredService<CrdtProjectsService>();
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<IDbContextFactory<LcmCrdtDbContext>>().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<CrdtProjectsService>();
// 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<IDbContextFactory<LcmCrdtDbContext>>().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<CrdtProjectsService>();
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<CrdtProjectsService>();
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<IDbContextFactory<LcmCrdtDbContext>>().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");
Expand All @@ -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);
Expand All @@ -131,7 +61,7 @@ public async Task TemplatedProject_DoesNotGetRedundantSeedOnOpen()
var morphTypeCreatingChanges = await dbContext.Database.SqlQuery<int>(
$"""
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");
Expand Down
2 changes: 1 addition & 1 deletion backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public async Task CanCreateExampleProject()
var crdtProjectsService = asyncScope.ServiceProvider.GetRequiredService<CrdtProjectsService>();
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();
Expand Down
5 changes: 1 addition & 4 deletions backend/FwLite/LcmCrdt/CrdtProjectsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,7 @@ public virtual async Task<CrdtProject> 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)
Expand Down
12 changes: 0 additions & 12 deletions backend/FwLite/LcmCrdt/CurrentProjectService.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<DataModel>();
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);
Expand Down
13 changes: 0 additions & 13 deletions backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs

This file was deleted.

Loading