diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs index a5223bedc3..d4f7a9c243 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs @@ -72,15 +72,19 @@ public async Task CanSyncAllChangesWithDuplicates() change.GetType()) as IChange; duplicateChange.Should().NotBeNull(); duplicateChange.GetType().Should().Be(change.GetType()); - // we can't create duplicate entities with the same ID - if (IsCreateChange(change)) duplicateChange.EntityId = Guid.NewGuid(); - else if (duplicateChange is AddTranslationChange addTranslationChange) + await fixture.DataModel.AddChange(Guid.NewGuid(), duplicateChange); + + if (change.SupportsNewEntity()) { - // translations aren't "primary entities" created with a CreateChange<> - // but they are still (correctly) enforced to have unique IDs (at least within a single ExampleSentence) - addTranslationChange.Translation.Id = Guid.NewGuid(); + // The previous duplicate change is presumably a no-op, so we'll make a duplicate entity with a different ID as well. + var duplicateCreateChange = JsonSerializer.Deserialize( + JsonSerializer.Serialize(change, Options), + change.GetType()) as IChange; + duplicateCreateChange.Should().NotBeNull(); + duplicateCreateChange.EntityId = Guid.NewGuid(); + duplicateCreateChange.GetType().Should().Be(change.GetType()); + await fixture.DataModel.AddChange(Guid.NewGuid(), duplicateCreateChange); } - await fixture.DataModel.AddChange(Guid.NewGuid(), duplicateChange); var allEntries = await fixture.Api.GetEntries().ToArrayAsync(); var result = await EntrySync.SyncFull(allEntries, allEntries, fixture.Api); @@ -256,18 +260,4 @@ private static IEnumerable GetAllChanges() new RemoteResourceUploadedChange(createRemoteResourcePendingUploadChange.EntityId, "test-remote-id"), [createRemoteResourcePendingUploadChange]); } - - private static bool IsCreateChange(IChange obj) - { - var type = obj.GetType(); - while (type != null && type != typeof(object)) - { - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CreateChange<>)) - { - return true; - } - type = type.BaseType; - } - return false; - } } diff --git a/backend/FwLite/LcmCrdt/Changes/ExampleSentences/AddTranslationChange.cs b/backend/FwLite/LcmCrdt/Changes/ExampleSentences/AddTranslationChange.cs index a5ccf1663e..dc48668014 100644 --- a/backend/FwLite/LcmCrdt/Changes/ExampleSentences/AddTranslationChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/ExampleSentences/AddTranslationChange.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using SIL.Extensions; using SIL.Harmony.Changes; using SIL.Harmony.Core; @@ -12,10 +11,8 @@ public class AddTranslationChange(Guid entityId, Translation translation) : Edit public override ValueTask ApplyChange(ExampleSentence entity, IChangeContext context) { - var existingTranslation = entity.Translations.FirstOrDefault(t => t.Id == Translation.Id); - Debug.Assert(existingTranslation == null, $"Translation with ID {Translation.Id} already exists in the ExampleSentence with ID ({entity.Id})"); - if (existingTranslation != null) entity.Translations.RemoveAll(t => t.Id == Translation.Id); - + // could happen if Chorus recreates a translation due to a merge conflict. + entity.Translations.RemoveAll(t => t.Id == Translation.Id); entity.Translations.Add(Translation); return ValueTask.CompletedTask; } diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 4bfb162fd1..a11ece856b 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -311,7 +311,8 @@ public async Task CreateComplexFormComponent(ComplexFormCo var existing = await repo.FindComplexFormComponent(complexFormComponent); if (existing is null) { - var betweenIds = between is null ? null : new BetweenPosition(between.Previous?.Id, between.Next?.Id); + // todo test between items missing IDs (i.e. from LibLCM) + var betweenIds = between is null ? null : await between.MapAsync(async c => (await repo.FindComplexFormComponent(c))?.Id); var addEntryComponentChange = await repo.CreateComplexFormComponentChange(complexFormComponent, betweenIds); await AddChange(addEntryComponentChange); return await repo.FindComplexFormComponent(addEntryComponentChange.EntityId); @@ -336,7 +337,11 @@ public async Task MoveComplexFormComponent(ComplexFormComponent component, Betwe public async Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) { - await AddChange(new DeleteChange(complexFormComponent.Id)); + // 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; + await AddChange(new DeleteChange(existing.Id)); } public async Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) diff --git a/backend/FwLite/MiniLcm.Tests/ExampleSentenceTestsBase.cs b/backend/FwLite/MiniLcm.Tests/ExampleSentenceTestsBase.cs index 0cd3d00ada..8c0c50e87d 100644 --- a/backend/FwLite/MiniLcm.Tests/ExampleSentenceTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/ExampleSentenceTestsBase.cs @@ -154,4 +154,23 @@ public async Task RemoveTranslation_RemovesExistingTranslation() reloaded.Should().NotBeNull(); reloaded.Translations.Should().BeEmpty(); } + + /// + /// Tests that a deleted example-sentence can be recreated. + /// This is necessary if Chorus recreates an example-sentence due to a merge conflict. + /// + [Fact] + public async Task RecreateDeletedExampleSentence() + { + var initial = await Api.GetExampleSentence(_entryId, _senseId, _exampleSentenceId); + initial.Should().NotBeNull(); + + await Api.DeleteExampleSentence(_entryId, _senseId, _exampleSentenceId); + var deleted = await Api.GetExampleSentence(_entryId, _senseId, _exampleSentenceId); + deleted.Should().BeNull(); + + var recreated = await Api.CreateExampleSentence(_entryId, _senseId, initial); + recreated.Should().NotBeNull(); + recreated.Should().BeEquivalentTo(initial); + } } diff --git a/backend/FwLite/MiniLcm.Tests/SenseTestsBase.cs b/backend/FwLite/MiniLcm.Tests/SenseTestsBase.cs index 83df2a1ad5..78d4d93e35 100644 --- a/backend/FwLite/MiniLcm.Tests/SenseTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/SenseTestsBase.cs @@ -34,4 +34,23 @@ public async Task Get_ExistingSense_ReturnsSense() sense.Should().NotBeNull(); sense.Gloss["en"].Should().Be("new-sense-gloss"); } + + /// + /// Tests that a deleted sense can be recreated. + /// This is necessary if Chorus recreates a sense due to a merge conflict. + /// + [Fact] + public async Task RecreateDeletedSense() + { + var initial = await Api.GetSense(_entryId, _senseId); + initial.Should().NotBeNull(); + + await Api.DeleteSense(_entryId, _senseId); + var deleted = await Api.GetSense(_entryId, _senseId); + deleted.Should().BeNull(); + + var recreated = await Api.CreateSense(_entryId, initial); + recreated.Should().NotBeNull(); + recreated.Should().BeEquivalentTo(initial); + } } diff --git a/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs index bb7b6ba143..de2f12728f 100644 --- a/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs @@ -333,4 +333,23 @@ public async Task UpdateEntry_CanReorderComponents(string before, string after, actualOrderValues.Should().Be(expectedOrderValues); } } + + /// + /// Tests that a deleted entry can be recreated. + /// This is necessary if Chorus recreates an entry due to a merge conflict. + /// + [Fact] + public async Task RecreateDeletedEntry() + { + var initial = await Api.GetEntry(Entry1Id); + initial.Should().NotBeNull(); + + await Api.DeleteEntry(Entry1Id); + var deleted = await Api.GetEntry(Entry1Id); + deleted.Should().BeNull(); + + var recreated = await Api.CreateEntry(initial); + recreated.Should().NotBeNull(); + recreated.Should().BeEquivalentTo(initial); + } } diff --git a/backend/harmony b/backend/harmony index 4c7dc0ae28..c396d05f6b 160000 --- a/backend/harmony +++ b/backend/harmony @@ -1 +1 @@ -Subproject commit 4c7dc0ae28a5ee08594a5ce3053f3067342d21a7 +Subproject commit c396d05f6b377432bf1ad94dd80ea41fccb5a8ac