Skip to content
Merged
6 changes: 3 additions & 3 deletions backend/FwLite/FwDataMiniLcmBridge/Api/LcmHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,22 @@ internal static class LcmHelpers
{
var citationFormTs =
ws.HasValue ? entry.CitationForm.get_String(ws.Value)
: entry.CitationForm.StringCount > 0 ? entry.CitationForm.GetStringFromIndex(0, out var _)
: entry.CitationForm.StringCount > 0 ? entry.CitationForm.BestVernacularAlternative
: null;
var citationForm = citationFormTs?.Text?.Trim(WhitespaceChars);

if (!string.IsNullOrEmpty(citationForm)) return citationForm;

var lexemeFormTs =
ws.HasValue ? entry.LexemeFormOA?.Form.get_String(ws.Value)
: entry.LexemeFormOA?.Form.StringCount > 0 ? entry.LexemeFormOA?.Form.GetStringFromIndex(0, out var _)
: entry.LexemeFormOA?.Form.StringCount > 0 ? entry.LexemeFormOA?.Form.BestVernacularAlternative
: null;
var lexemeForm = lexemeFormTs?.Text?.Trim(WhitespaceChars);

return lexemeForm;
}

internal static string? LexEntryHeadwordOrUnknown(this ILexEntry entry, int? ws = null)
internal static string LexEntryHeadwordOrUnknown(this ILexEntry entry, int? ws = null)
Comment thread
imnasnainaec marked this conversation as resolved.
{
var headword = entry.LexEntryHeadword(ws);
return string.IsNullOrEmpty(headword) ? Entry.UnknownHeadword : headword;
Expand Down
206 changes: 177 additions & 29 deletions backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Text;
using FwDataMiniLcmBridge.Api;
using FwLiteProjectSync.Tests.Fixtures;
using LcmCrdt;
using MiniLcm;
using MiniLcm.Models;
using MiniLcm.SyncHelpers;
Expand All @@ -8,52 +10,48 @@

namespace FwLiteProjectSync.Tests;

public class CrdtEntrySyncTests(SyncFixture fixture) : EntrySyncTestsBase(fixture)
public class CrdtEntrySyncTests(ExtraWritingSystemsSyncFixture fixture) : EntrySyncTestsBase(fixture)
{
private static readonly AutoFaker AutoFaker = new(AutoFakerDefault.Config);

protected override IMiniLcmApi GetApi(SyncFixture fixture)
{
return fixture.CrdtApi;
}

[Fact]
public async Task CanSyncRandomEntries()
{
var createdEntry = await Api.CreateEntry(await AutoFaker.EntryReadyForCreation(Api));
var after = await AutoFaker.EntryReadyForCreation(Api, entryId: createdEntry.Id);

after.Senses = [.. AutoFaker.Faker.Random.Shuffle([
// copy some senses over, so moves happen
..AutoFaker.Faker.Random.ListItems(createdEntry.Senses),
..after.Senses
])];

await EntrySync.SyncFull(createdEntry, after, Api);
var actual = await Api.GetEntry(after.Id);
actual.Should().NotBeNull();
actual.Should().BeEquivalentTo(after, options => options
.For(e => e.Senses).Exclude(s => s.Order)
.For(e => e.Components).Exclude(c => c.Order)
.For(e => e.ComplexForms).Exclude(c => c.Order)
.For(e => e.Senses).For(s => s.ExampleSentences).Exclude(e => e.Order)
);
}
}

public class FwDataEntrySyncTests(SyncFixture fixture) : EntrySyncTestsBase(fixture)
public class FwDataEntrySyncTests(ExtraWritingSystemsSyncFixture fixture) : EntrySyncTestsBase(fixture)
{
protected override IMiniLcmApi GetApi(SyncFixture fixture)
{
return fixture.FwDataApi;
}

// this will notify us when we start syncing MorphType (if that ever happens)
[Fact]
public async Task FwDataApiDoesNotUpdateMorphType()
Comment thread
myieye marked this conversation as resolved.
{
// arrange
var entry = await Api.CreateEntry(new()
{
LexemeForm = { { "en", "morph-type-test" } },
MorphType = MorphType.BoundStem
});

// act
var updatedEntry = entry.Copy();
updatedEntry.MorphType = MorphType.Suffix;
await EntrySync.SyncFull(entry, updatedEntry, Api);

// assert
var actual = await Api.GetEntry(entry.Id);
actual.Should().NotBeNull();
actual.MorphType.Should().Be(MorphType.BoundStem);
}
}

public abstract class EntrySyncTestsBase(SyncFixture fixture) : IClassFixture<SyncFixture>, IAsyncLifetime
public abstract class EntrySyncTestsBase(ExtraWritingSystemsSyncFixture fixture) : IClassFixture<ExtraWritingSystemsSyncFixture>, IAsyncLifetime
{
public async Task InitializeAsync()

Check warning on line 53 in backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs

View workflow job for this annotation

GitHub Actions / Build FW Lite and run tests

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 53 in backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs

View workflow job for this annotation

GitHub Actions / Build FW Lite and run tests

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
await _fixture.EnsureDefaultVernacularWritingSystemExistsInCrdt();
Api = GetApi(_fixture);
}

Expand All @@ -67,6 +65,156 @@
private readonly SyncFixture _fixture = fixture;
protected IMiniLcmApi Api = null!;

private static readonly AutoFaker AutoFaker = new(AutoFakerDefault.MakeConfig(
ExtraWritingSystemsSyncFixture.VernacularWritingSystems));

public enum ApiType
{
Crdt,
FwData
}

// Not all of these cases are realistic, but they should all work
// An especially critical case is syncing data to crdt that has been round-tripped through fwdata first.
[Theory]
[InlineData(ApiType.Crdt)]
[InlineData(ApiType.FwData)]
[InlineData(null)]
public async Task CanSyncRandomEntries(ApiType? roundTripApiType)
{
// arrange
var currentApiType = Api switch
{
FwDataMiniLcmApi => ApiType.FwData,
CrdtMiniLcmApi => ApiType.Crdt,
// This works now, because we're not currently wrapping Api,
// but if we ever do, then we want this to throw, so we know we need to detect the api differently.
_ => throw new InvalidOperationException("Unknown API type")
};

IMiniLcmApi? roundTripApi = roundTripApiType switch
{
ApiType.Crdt => _fixture.CrdtApi,
ApiType.FwData => _fixture.FwDataApi,
_ => null
};

var before = AutoFaker.Generate<Entry>();
var after = AutoFaker.Generate<Entry>();
after.Id = before.Id;

await Api.PrepareToCreateEntry(before);
await Api.PrepareToCreateEntry(after);

if (roundTripApi is not null && currentApiType != roundTripApiType)
Comment thread
imnasnainaec marked this conversation as resolved.
{
// We have to prepare while before and after have no overlap (i.e. before we start mixing parts of before into after),
// otherwise "PrepareToCreateEntry" would fail due to trying to create duplicate related entities.
// After this we can't ADD anything to after that has dependencies
// (e.g. ExampleSentences are fine, because they're created as part of an entry, but Parts of speech aren't)
await roundTripApi.PrepareToCreateEntry(before);
await roundTripApi.PrepareToCreateEntry(after);
}

Comment thread
imnasnainaec marked this conversation as resolved.
after.Senses = [..
Comment thread
myieye marked this conversation as resolved.
Outdated
// shuffle to cause moves
AutoFaker.Faker.Random.Shuffle([..
// keep some, remove others
AutoFaker.Faker.Random.ListItems(before.Senses).Select(createdSense =>
{
var copy = createdSense.Copy();
copy.ExampleSentences = [..
// shuffle to cause moves
AutoFaker.Faker.Random.Shuffle([..
// keep some, remove others
AutoFaker.Faker.Random.ListItems(copy.ExampleSentences),
// add new
AutoFaker.ExampleSentence(copy),
AutoFaker.ExampleSentence(copy),
])];
return copy;
}),
// keep new
..after.Senses
])];

after.ComplexForms = [..
// shuffle to cause moves
AutoFaker.Faker.Random.Shuffle([..
// keep some, remove others
AutoFaker.Faker.Random.ListItems(before.ComplexForms).Select(createdCfc =>
{
var copy = createdCfc.Copy();
copy.ComponentHeadword = after.Headword();
return copy;
}),
// keep new
..after.ComplexForms
])];

after.Components = [..
// shuffle to cause moves
AutoFaker.Faker.Random.Shuffle([..
// keep some, remove others
AutoFaker.Faker.Random.ListItems(before.Components).Select(createdCfc =>
{
var copy = createdCfc.Copy();
copy.ComplexFormHeadword = after.Headword();
return copy;
}),
// keep new
..after.Components
])];

// expected should not be round-tripped, because an api might manipulate it somehow.
// We expect final result to be equivalent to this "raw"/untouched, requested state.
var expected = after;
Comment thread
myieye marked this conversation as resolved.
Outdated

if (roundTripApi is not null)
{
// round-tripping ensures we're dealing with realistic data
// (e.g. in fwdata ComplexFormComponents do not have an Id)
before = await roundTripApi.CreateEntry(before);
await roundTripApi.DeleteEntry(before.Id);
after = await roundTripApi.CreateEntry(after);
await roundTripApi.DeleteEntry(after.Id);
}

// before should not be round-tripped here. That's handled above.
await Api.CreateEntry(before);

// act
await EntrySync.SyncFull(before, after, Api);
var actual = await Api.GetEntry(after.Id);

// assert
actual.Should().NotBeNull();
actual.Should().BeEquivalentTo(after, options =>
{
options = options
.WithStrictOrdering()
.WithoutStrictOrderingFor(e => e.ComplexForms) // sorted alphabetically
.WithoutStrictOrderingFor(e => e.Path.EndsWith($".{nameof(Sense.SemanticDomains)}")) // not sorted
.For(e => e.Senses).Exclude(s => s.Order)
.For(e => e.Components).Exclude(c => c.Order)
.For(e => e.ComplexForms).Exclude(c => c.Order)
.For(e => e.Senses).For(s => s.ExampleSentences).Exclude(e => e.Order);
if (currentApiType == ApiType.Crdt)
{
// does not yet update Headwords 😕
options = options
.For(e => e.Components).Exclude(c => c.ComplexFormHeadword)
.For(e => e.ComplexForms).Exclude(c => c.ComponentHeadword);
}
if (currentApiType == ApiType.FwData)
{
// does not support changing MorphType yet (see UpdateEntryProxy.MorphType)
options = options.Excluding(e => e.MorphType);
}
return options;
});
}

[Fact]
public async Task NormalizesStringsToNFD()
{
Expand Down
67 changes: 37 additions & 30 deletions backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,53 @@
using FwDataMiniLcmBridge.LcmUtils;
using LcmCrdt;
using LexCore.Utils;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using MiniLcm.Models;

namespace FwLiteProjectSync.Tests.Fixtures;

public class ExtraWritingSystemsSyncFixture : SyncFixture
{
private static readonly string[] ExtraVernacularWritingSystems = ["es", "fr"];
public static readonly string[] VernacularWritingSystems = [
DefaultVernacularWritingSystem,
.. ExtraVernacularWritingSystems,
];

public override async Task InitializeAsync()
{
await base.InitializeAsync();

foreach (var ws in ExtraVernacularWritingSystems)
{
await FwDataApi.CreateWritingSystem(new WritingSystem
{
Id = Guid.NewGuid(),
WsId = ws,
Name = ws,
Abbreviation = ws,
Font = "Arial",
Type = WritingSystemType.Vernacular
});
}

// Crdt data doesn't strictly require writing-systems to exist in order for them to be used.
// However, a (default) vernacular writing system is required in order to query for entries.
// This is not part of SyncFixture, because our core sync integration tests benefit from having a CRDT project that's as empty as possible.
var firstVernacularWs = (await FwDataApi.GetWritingSystems()).Vernacular.First();
await CrdtApi.CreateWritingSystem(firstVernacularWs);
}
}

public class SyncFixture : IAsyncLifetime
{
private readonly AsyncServiceScope _services;

public CrdtFwdataProjectSyncService SyncService =>
_services.ServiceProvider.GetRequiredService<CrdtFwdataProjectSyncService>();
public IServiceProvider Services => _services.ServiceProvider;
protected static readonly string DefaultVernacularWritingSystem = "en";
private readonly string _projectName;
private readonly string _projectFolder;
private readonly IDisposable _cleanup;
Expand All @@ -40,7 +74,7 @@ public SyncFixture() : this("sena-3_" + Guid.NewGuid().ToString().Split("-")[0],
{
}

public async Task InitializeAsync()
public virtual async Task InitializeAsync()
{
lock (_preCleanupLock)
{
Expand All @@ -59,7 +93,7 @@ public async Task InitializeAsync()
Directory.CreateDirectory(projectsFolder);
var fwDataProject = new FwDataProject(_projectName, projectsFolder);
_services.ServiceProvider.GetRequiredService<IProjectLoader>()
.NewProject(fwDataProject, "en", "en");
.NewProject(fwDataProject, "en", DefaultVernacularWritingSystem);
FwDataApi = _services.ServiceProvider.GetRequiredService<FwDataFactory>().GetFwDataMiniLcmApi(fwDataProject, false);

var crdtProjectsFolder =
Expand All @@ -68,7 +102,6 @@ public async Task InitializeAsync()
var crdtProject = await _services.ServiceProvider.GetRequiredService<CrdtProjectsService>()
.CreateProject(new(_projectName, _projectName, FwProjectId: FwDataApi.ProjectId, SeedNewProjectData: false));
CrdtApi = (CrdtMiniLcmApi)await _services.ServiceProvider.OpenCrdtProject(crdtProject);

}

public async Task DisposeAsync()
Expand All @@ -85,30 +118,4 @@ public void DeleteSyncSnapshot()
var snapshotPath = CrdtFwdataProjectSyncService.SnapshotPath(FwDataApi.Project);
if (File.Exists(snapshotPath)) File.Delete(snapshotPath);
}

private readonly SemaphoreSlim _vernacularSemaphore = new(1, 1);

// a vernacular writing system is required in order to query for entries
// this is optional setup, because our core sync integration tests benefit from having a CRDT project that's as empty as possible
public async Task EnsureDefaultVernacularWritingSystemExistsInCrdt()
{
// This is optionally called from tests that consume this fixture, so it could get called multiple times in parallel
if (!await _vernacularSemaphore.WaitAsync(100))
{
throw new InvalidOperationException("Timeout waiting for vernacular semaphore");
}

try
{
if ((await CrdtApi.GetWritingSystems()).Vernacular.Length == 0)
{
var firstVernacularWs = (await FwDataApi.GetWritingSystems()).Vernacular.First();
await CrdtApi.CreateWritingSystem(firstVernacularWs);
}
}
finally
{
_vernacularSemaphore.Release();
}
}
}
1 change: 0 additions & 1 deletion backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,6 @@ public async Task MoveComplexFormComponent(ComplexFormComponent component, Betwe

public async Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent)
{
// todo test missing ID (i.e. from LibLCM)
await using var repo = await repoFactory.CreateRepoAsync();
var existing = await repo.FindComplexFormComponent(complexFormComponent);
if (existing is null) return;
Expand Down
Loading
Loading