Skip to content
Merged
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
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)
{
var headword = entry.LexEntryHeadword(ws);
return string.IsNullOrEmpty(headword) ? Entry.UnknownHeadword : headword;
Expand Down
225 changes: 195 additions & 30 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,53 +10,50 @@

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()
{
// 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()
public Task InitializeAsync()
{
await _fixture.EnsureDefaultVernacularWritingSystemExistsInCrdt();
Api = GetApi(_fixture);
return Task.CompletedTask;
}

public Task DisposeAsync()
Expand All @@ -67,6 +66,172 @@ public Task DisposeAsync()
private readonly SyncFixture _fixture = fixture;
protected IMiniLcmApi Api = null!;

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

public enum ApiType
{
Crdt,
FwData
}

// The round-tripping api is not what is under test here. It's purely for preprocessing.
// It's so that that the data under test is being read from a real API (i.e. fwdata or crdt)
// and thus reflects whatever nuances that API may have.
//
// Not all of the test cases are realistic, but they should all work and they reflect the idea
// that "any MiniLcmApi implementation should be compatible with any other implementation".
// Even the unrealistic test cases could potentially expose unexpected, undesirable nuances in API behaviour.
// They also reflect the diversity of pipelines real entries might go through.
// For example, a currently real scenario is that "after" is read from fwdata and "before" is read from crdt
// and then round-tripped through a json file.
// That case is not explicitly covered here.
//
// The most critical test cases are:
// Api == CrdtApi and RoundTripApi == FwDataApi
// Api == FwDataApi and RoundTripApi == CrdtApi
// (though, as noted above, this case doesn't perfectly reflect real usage)
[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;

// 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 owned/part of an entry.
// Parts of speech, on the other hand, are not owned by an entry.
await Api.PrepareToCreateEntry(before);
await Api.PrepareToCreateEntry(after);

if (roundTripApi is not null && currentApiType != roundTripApiType)
{
await roundTripApi.PrepareToCreateEntry(before);
await roundTripApi.PrepareToCreateEntry(after);
}

// keep some old senses, remove others
var someRandomBeforeSenses = 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, and shuffle to cause moves
after.Senses = [.. AutoFaker.Faker.Random.Shuffle([.. someRandomBeforeSenses, .. 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 the final result to be equivalent to this "raw"/untouched, requested state.
var expected = after.Copy();

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
Loading
Loading