Skip to content

Commit

Permalink
Add test for syncing complex forms twice (#1256)
Browse files Browse the repository at this point in the history
* Add integration test for syncing sena-3

* prevent duplicating complex forms which are the same/already exist

* catch and throw some exceptions with additional context to help debugging

* change test project vernacular ws and change how ComplexFormComponent headwords get set so that they match what we get from fieldworks

* ensure root service provider is cleaned up in fixtures to avoid issue where test host hangs once tests are done

* ensure headword is stable

* flatten Complex Form types from FW so we import all of them, not just the top level

* ensure that all entries are created before complex forms during import to avoid issues where the complex form does not get created because it's referencing an entry which does not exist

* don't try to download sena3 from lexbox, just download the zip from google drive

* isolate sena3 sync tests rather than using the same project for each test

* use HgRunner instead of trying to locate mercurial manually

---------

Co-authored-by: Kevin Hahn <[email protected]>
  • Loading branch information
rmunn and hahn-kev authored Nov 28, 2024
1 parent e32f341 commit 18530d4
Show file tree
Hide file tree
Showing 27 changed files with 818 additions and 119 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ namespace FwDataMiniLcmBridge.Tests.Fixtures;

public static class FwDataTestsKernel
{
public static IServiceCollection AddTestFwDataBridge(this IServiceCollection services)
public static IServiceCollection AddTestFwDataBridge(this IServiceCollection services, bool mockProjectLoader = true)
{
services.AddFwDataBridge();
services.AddSingleton<IConfiguration>(_ => new ConfigurationRoot([]));
services.AddSingleton<MockFwProjectLoader>();
services.AddSingleton<IProjectLoader>(sp => sp.GetRequiredService<MockFwProjectLoader>());
services.AddSingleton<FieldWorksProjectList, MockFwProjectList>();
if (mockProjectLoader)
{
services.AddSingleton<MockFwProjectLoader>();
services.AddSingleton<IProjectLoader>(sp => sp.GetRequiredService<MockFwProjectLoader>());
services.AddSingleton<FieldWorksProjectList, MockFwProjectList>();
}
return services;
}
}
106 changes: 62 additions & 44 deletions backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using FwDataMiniLcmBridge.LcmUtils;
using Microsoft.Extensions.Logging;
using MiniLcm;
using MiniLcm.Exceptions;
using MiniLcm.Models;
using MiniLcm.SyncHelpers;
using SIL.LCModel;
Expand Down Expand Up @@ -37,6 +38,8 @@ public class FwDataMiniLcmApi(Lazy<LcmCache> cacheLazy, bool onCloseSave, ILogge
private ICmTranslationFactory CmTranslationFactory => Cache.ServiceLocator.GetInstance<ICmTranslationFactory>();
private ICmPossibilityRepository CmPossibilityRepository => Cache.ServiceLocator.GetInstance<ICmPossibilityRepository>();
private ICmPossibilityList ComplexFormTypes => Cache.LangProject.LexDbOA.ComplexEntryTypesOA;
private IEnumerable<ILexEntryType> ComplexFormTypesFlattened => ComplexFormTypes.PossibilitiesOS.Cast<ILexEntryType>().Flatten();

private ICmPossibilityList VariantTypes => Cache.LangProject.LexDbOA.VariantEntryTypesOA;

public void Dispose()
Expand Down Expand Up @@ -326,12 +329,9 @@ public Task DeleteSemanticDomain(Guid id)

public IAsyncEnumerable<ComplexFormType> GetComplexFormTypes()
{
return ComplexFormTypes.PossibilitiesOS
.Select(ToComplexFormType)
.ToAsyncEnumerable();
return ComplexFormTypesFlattened.Select(ToComplexFormType).ToAsyncEnumerable();
}

private ComplexFormType ToComplexFormType(ICmPossibility t)
private ComplexFormType ToComplexFormType(ILexEntryType t)
{
return new ComplexFormType() { Id = t.Guid, Name = FromLcmMultiString(t.Name) };
}
Expand All @@ -350,7 +350,7 @@ public Task<ComplexFormType> CreateComplexFormType(ComplexFormType complexFormTy
ComplexFormTypes.PossibilitiesOS.Add(lexComplexFormType);
UpdateLcmMultiString(lexComplexFormType.Name, complexFormType.Name);
});
return Task.FromResult(ToComplexFormType(ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormType.Id)));
return Task.FromResult(ToComplexFormType(ComplexFormTypesFlattened.Single(c => c.Guid == complexFormType.Id)));
}

public IAsyncEnumerable<VariantType> GetVariantTypes()
Expand Down Expand Up @@ -390,6 +390,15 @@ private Entry FromLexEntry(ILexEntry entry)
};
}

private string LexEntryHeadword(ILexEntry entry)
{
return new Entry()
{
LexemeForm = FromLcmMultiString(entry.LexemeFormOA.Form),
CitationForm = FromLcmMultiString(entry.CitationForm),
}.Headword();
}

private IList<ComplexFormType> ToComplexFormTypes(ILexEntry entry)
{
return entry.ComplexFormEntryRefs.SingleOrDefault()
Expand Down Expand Up @@ -438,9 +447,9 @@ private ComplexFormComponent ToEntryReference(ILexEntry component, ILexEntry com
return new ComplexFormComponent
{
ComponentEntryId = component.Guid,
ComponentHeadword = component.HeadWord.Text,
ComponentHeadword = LexEntryHeadword(component),
ComplexFormEntryId = complexEntry.Guid,
ComplexFormHeadword = complexEntry.HeadWord.Text
ComplexFormHeadword = LexEntryHeadword(complexEntry)
};
}

Expand All @@ -452,7 +461,7 @@ private ComplexFormComponent ToSenseReference(ILexSense componentSense, ILexEntr
ComponentSenseId = componentSense.Guid,
ComponentHeadword = componentSense.Entry.HeadWord.Text,
ComplexFormEntryId = complexEntry.Guid,
ComplexFormHeadword = complexEntry.HeadWord.Text
ComplexFormHeadword = LexEntryHeadword(complexEntry)
};
}

Expand Down Expand Up @@ -559,40 +568,48 @@ public IAsyncEnumerable<Entry> SearchEntries(string query, QueryOptions? options
public async Task<Entry> CreateEntry(Entry entry)
{
entry.Id = entry.Id == default ? Guid.NewGuid() : entry.Id;
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Entry",
"Remove entry",
Cache.ServiceLocator.ActionHandler,
() =>
{
var lexEntry = LexEntryFactory.Create(entry.Id, Cache.ServiceLocator.GetInstance<ILangProjectRepository>().Singleton.LexDbOA);
lexEntry.LexemeFormOA = Cache.ServiceLocator.GetInstance<IMoStemAllomorphFactory>().Create();
UpdateLcmMultiString(lexEntry.LexemeFormOA.Form, entry.LexemeForm);
UpdateLcmMultiString(lexEntry.CitationForm, entry.CitationForm);
UpdateLcmMultiString(lexEntry.LiteralMeaning, entry.LiteralMeaning);
UpdateLcmMultiString(lexEntry.Comment, entry.Note);

foreach (var sense in entry.Senses)
{
CreateSense(lexEntry, sense);
}

//form types should be created before components, otherwise the form type "unspecified" will be added
foreach (var complexFormType in entry.ComplexFormTypes)
{
AddComplexFormType(lexEntry, complexFormType.Id);
}

foreach (var component in entry.Components)
{
AddComplexFormComponent(lexEntry, component);
}

foreach (var complexForm in entry.ComplexForms)
try
{
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Entry",
"Remove entry",
Cache.ServiceLocator.ActionHandler,
() =>
{
var complexLexEntry = EntriesRepository.GetObject(complexForm.ComplexFormEntryId);
AddComplexFormComponent(complexLexEntry, complexForm);
}
});
var lexEntry = LexEntryFactory.Create(entry.Id,
Cache.ServiceLocator.GetInstance<ILangProjectRepository>().Singleton.LexDbOA);
lexEntry.LexemeFormOA = Cache.ServiceLocator.GetInstance<IMoStemAllomorphFactory>().Create();
UpdateLcmMultiString(lexEntry.LexemeFormOA.Form, entry.LexemeForm);
UpdateLcmMultiString(lexEntry.CitationForm, entry.CitationForm);
UpdateLcmMultiString(lexEntry.LiteralMeaning, entry.LiteralMeaning);
UpdateLcmMultiString(lexEntry.Comment, entry.Note);

foreach (var sense in entry.Senses)
{
CreateSense(lexEntry, sense);
}

//form types should be created before components, otherwise the form type "unspecified" will be added
foreach (var complexFormType in entry.ComplexFormTypes)
{
AddComplexFormType(lexEntry, complexFormType.Id);
}

foreach (var component in entry.Components)
{
AddComplexFormComponent(lexEntry, component);
}

foreach (var complexForm in entry.ComplexForms)
{
var complexLexEntry = EntriesRepository.GetObject(complexForm.ComplexFormEntryId);
AddComplexFormComponent(complexLexEntry, complexForm);
}
});
}
catch (Exception e)
{
throw new CreateObjectException($"Failed to create entry {entry}", e);
}

return await GetEntry(entry.Id) ?? throw new InvalidOperationException("Entry was not created");
}
Expand Down Expand Up @@ -693,15 +710,16 @@ internal void AddComplexFormType(ILexEntry lexEntry, Guid complexFormTypeId)
entryRef.HideMinorEntry = 0;
}

var lexEntryType = (ILexEntryType)ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormTypeId);
var lexEntryType = ComplexFormTypesFlattened.Single(c => c.Guid == complexFormTypeId);
entryRef.ComplexEntryTypesRS.Add(lexEntryType);
}

internal void RemoveComplexFormType(ILexEntry lexEntry, Guid complexFormTypeId)
{
ILexEntryRef? entryRef = lexEntry.ComplexFormEntryRefs.SingleOrDefault();
if (entryRef is null) return;
var lexEntryType = (ILexEntryType)ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormTypeId);
var lexEntryType = entryRef.ComplexEntryTypesRS.SingleOrDefault(c => c.Guid == complexFormTypeId);
if (lexEntryType is null) return;
entryRef.ComplexEntryTypesRS.Remove(lexEntryType);
}

Expand Down
18 changes: 18 additions & 0 deletions backend/FwLite/FwDataMiniLcmBridge/Api/PossibilityExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using SIL.LCModel;

namespace FwDataMiniLcmBridge.Api;

public static class PossibilityExtensions
{
public static IEnumerable<T> Flatten<T>(this IEnumerable<T> enumerable) where T : ICmPossibility
{
foreach (var cmPossibility in enumerable)
{
yield return cmPossibility;
foreach (var child in Flatten(cmPossibility.SubPossibilitiesOS.Cast<T>()))
{
yield return child;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace FwDataMiniLcmBridge.Api.UpdateProxy;

public class UpdateEntryProxy : Entry
public record UpdateEntryProxy : Entry
{
private readonly ILexEntry _lcmEntry;
private readonly FwDataMiniLcmApi _lexboxLcmApi;
Expand Down
2 changes: 1 addition & 1 deletion backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ public async Task CanChangeComplexFormViaSync_ComplexForms()
[Fact]
public async Task CanChangeComplexFormTypeViaSync()
{
var complexFormType = await _fixture.CrdtApi.CreateComplexFormType(new() { Name = new() { { "en", "complexFormType" } } });
var entry = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "complexForm1" } } });
var complexFormType = await _fixture.CrdtApi.GetComplexFormTypes().FirstAsync();
var after = (Entry) entry.Copy();
after.ComplexFormTypes = [complexFormType];
await EntrySync.Sync(after, entry, _fixture.CrdtApi);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Chorus.VcsDrivers.Mercurial;
using SIL.CommandLineProcessing;
using SIL.PlatformUtilities;
using SIL.Progress;

namespace FwLiteProjectSync.Tests.Fixtures;

public static class MercurialTestHelper
{
private static readonly NullProgress NullProgress = new NullProgress();

private static string RunHgCommand(string repoPath, string args)
{
var result = HgRunner.Run(args, repoPath, 120, NullProgress);
if (result.ExitCode == 0) return result.StandardOutput;
throw new Exception(
$"hg {args} failed.\nStdOut: {result.StandardOutput}\nStdErr: {result.StandardError}");

}

public static void HgClean(string repoPath, string exclude)
{
RunHgCommand(repoPath, $"purge --no-confirm --exclude {exclude}");
}

public static void HgUpdate(string repoPath, string rev)
{
RunHgCommand(repoPath, $"update \"{rev}\"");
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.IO.Compression;
using FwDataMiniLcmBridge;
using FwDataMiniLcmBridge.Api;
using LcmCrdt;
using LexCore.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SIL.IO;
using SIL.Progress;

namespace FwLiteProjectSync.Tests.Fixtures;

public class Sena3Fixture : IAsyncLifetime
{
private static readonly HttpClient http = new HttpClient();

public async Task InitializeAsync()
{
var services = new ServiceCollection()
.AddSyncServices(nameof(Sena3Fixture), false);
var rootServiceProvider = services.BuildServiceProvider();
var fwProjectsFolder = rootServiceProvider.GetRequiredService<IOptions<FwDataBridgeConfig>>()
.Value
.ProjectsFolder;
if (Path.Exists(fwProjectsFolder)) Directory.Delete(fwProjectsFolder, true);
Directory.CreateDirectory(fwProjectsFolder);

var crdtProjectsFolder =
rootServiceProvider.GetRequiredService<IOptions<LcmCrdtConfig>>().Value.ProjectPath;
if (Path.Exists(crdtProjectsFolder)) Directory.Delete(crdtProjectsFolder, true);
rootServiceProvider.Dispose();

Check warning on line 31 in backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs

View workflow job for this annotation

GitHub Actions / Build FW Lite and run tests

Dispose synchronously blocks. Await DisposeAsync instead. (https://github.com/Microsoft/vs-threading/blob/main/doc/analyzers/VSTHRD103.md)

Directory.CreateDirectory(crdtProjectsFolder);
await DownloadSena3();
}

public async Task<(CrdtMiniLcmApi CrdtApi, FwDataMiniLcmApi FwDataApi, IServiceProvider services, IDisposable cleanup)> SetupProjects()
{
var sena3MasterCopy = await DownloadSena3();

var rootServiceProvider = new ServiceCollection()
.AddSyncServices(nameof(Sena3Fixture), false)
.BuildServiceProvider();
var cleanup = Defer.Action(() => rootServiceProvider.Dispose());
var services = rootServiceProvider.CreateAsyncScope().ServiceProvider;
var projectName = "sena-3_" + Guid.NewGuid().ToString("N");

var projectsFolder = services.GetRequiredService<IOptions<FwDataBridgeConfig>>()
.Value
.ProjectsFolder;
var fwDataProject = new FwDataProject(projectName, projectsFolder);
var fwDataProjectPath = Path.Combine(fwDataProject.ProjectsPath, fwDataProject.Name);
DirectoryHelper.Copy(sena3MasterCopy, fwDataProjectPath);
File.Move(Path.Combine(fwDataProjectPath, "sena-3.fwdata"), fwDataProject.FilePath);
var fwDataMiniLcmApi = services.GetRequiredService<FwDataFactory>().GetFwDataMiniLcmApi(fwDataProject, false);

var crdtProject = await services.GetRequiredService<ProjectsService>()
.CreateProject(new(projectName, FwProjectId: fwDataMiniLcmApi.ProjectId, SeedNewProjectData: false));
var crdtMiniLcmApi = (CrdtMiniLcmApi)await services.OpenCrdtProject(crdtProject);
return (crdtMiniLcmApi, fwDataMiniLcmApi, services, cleanup);
}

public Task DisposeAsync()
{
return Task.CompletedTask;
}

private async Task<Stream> DownloadSena3ProjectBackupStream()
{
var backupUrl = new Uri("https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS");
var result = await http.GetAsync(backupUrl, HttpCompletionOption.ResponseHeadersRead);
return await result.Content.ReadAsStreamAsync();
}

private async Task<string> DownloadSena3()
{
var tempFolder = Path.Combine(Path.GetTempPath(), nameof(Sena3Fixture));
var sena3MasterCopy = Path.Combine(tempFolder, "sena-3");
if (!Directory.Exists(sena3MasterCopy) || !File.Exists(Path.Combine(sena3MasterCopy, "sena-3.fwdata")))
{
Directory.CreateDirectory(sena3MasterCopy);
await using var zipStream = await DownloadSena3ProjectBackupStream();
//the zip file is structured like this: /sena-3/.hg
//by extracting it to tempFolder it should merge with sena-3
ZipFile.ExtractToDirectory(zipStream, tempFolder);

MercurialTestHelper.HgUpdate(sena3MasterCopy, "tip");
LfMergeBridge.LfMergeBridge.ReassembleFwdataFile(new NullProgress(), false, Path.Combine(sena3MasterCopy, "sena-3.fwdata"));
MercurialTestHelper.HgClean(sena3MasterCopy, "sena-3.fwdata");
}
return sena3MasterCopy;
}
}
Loading

0 comments on commit 18530d4

Please sign in to comment.