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
120 changes: 120 additions & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,126 @@ protected override IMiniLcmApi GetApi(SyncFixture fixture)
{
return fixture.CrdtApi;
}

// These delete-win cases live only in the CRDT subclass: the CRDT deletion must win when an object it
// deleted is still edited from the other side, whereas FwData intentionally still throws on a missing target.

[Fact]
public async Task SyncFull_EntryEditedButDeletedInCrdt_DoesNotThrow()
{
var entry = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "victim" } } });
await Api.DeleteEntry(entry.Id);
var after = entry.Copy();
after.CitationForm["en"] = "edited";

await EntrySync.SyncFull(entry, after, Api);

(await Api.GetEntry(entry.Id)).Should().BeNull();
}

[Fact]
public async Task SyncFull_SenseAddedToEntryDeletedInCrdt_DoesNotThrow()
{
var entry = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "victim" } } });
await Api.DeleteEntry(entry.Id);
var after = entry.Copy();
after.Senses.Add(new Sense { Id = Guid.NewGuid(), Gloss = { { "en", "gloss" } } });

await EntrySync.SyncFull(entry, after, Api);

(await Api.GetEntry(entry.Id)).Should().BeNull();
}

[Fact]
public async Task SyncFull_SenseEditedButDeletedInCrdt_DoesNotThrow()
{
var entry = await Api.CreateEntry(new()
{
Id = Guid.NewGuid(),
LexemeForm = { { "en", "victim" } },
Senses = [new Sense { Id = Guid.NewGuid(), Gloss = { { "en", "gloss" } } }]
});
await Api.DeleteSense(entry.Id, entry.Senses[0].Id);
var after = entry.Copy();
after.Senses[0].Gloss["en"] = "edited";

await EntrySync.SyncFull(entry, after, Api);

var actual = await Api.GetEntry(entry.Id);
actual.Should().NotBeNull();
actual.Senses.Should().BeEmpty();
}

[Fact]
public async Task SyncFull_ExampleSentenceEditedButDeletedInCrdt_DoesNotThrow()
{
var entry = await Api.CreateEntry(new()
{
Id = Guid.NewGuid(),
LexemeForm = { { "en", "victim" } },
Senses =
[
new Sense
{
Id = Guid.NewGuid(),
Gloss = { { "en", "gloss" } },
ExampleSentences = [new ExampleSentence { Id = Guid.NewGuid(), Sentence = { { "en", new RichString("sentence") } } }]
}
]
});
var sense = entry.Senses[0];
await Api.DeleteExampleSentence(entry.Id, sense.Id, sense.ExampleSentences[0].Id);
var after = entry.Copy();
after.Senses[0].ExampleSentences[0].Sentence["en"] = new RichString("edited");

await EntrySync.SyncFull(entry, after, Api);

var actual = await Api.GetEntry(entry.Id);
actual.Should().NotBeNull();
actual.Senses[0].ExampleSentences.Should().BeEmpty();
}

[Fact]
public async Task SyncFull_ComplexFormComponentReferencingEntryDeletedInCrdt_DoesNotThrow()
{
var component = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "component" } } });
var complexForm = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "complexForm" } } });
await Api.DeleteEntry(component.Id);
var after = complexForm.Copy();
after.Components.Add(ComplexFormComponent.FromEntries(complexForm, component));

await EntrySync.SyncFull(complexForm, after, Api);

(await Api.GetEntry(component.Id)).Should().BeNull();
(await Api.GetEntry(complexForm.Id))!.Components.Should().BeEmpty();
}
Comment thread
myieye marked this conversation as resolved.

[Fact]
public async Task SyncFull_ComplexFormComponentReorderedButEntryDeletedInCrdt_DoesNotThrow()
{
var componentA = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "a" } } });
var componentB = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "b" } } });
var complexForm = new Entry { Id = Guid.NewGuid(), LexemeForm = { { "en", "complexForm" } } };
complexForm.Components =
[
ComplexFormComponent.FromEntries(complexForm, componentA),
ComplexFormComponent.FromEntries(complexForm, componentB),
];
var before = await Api.CreateEntry(complexForm);
await Api.DeleteEntry(before.Id);

// Id-less components (as FwData produces them) force the move to resolve the now-deleted component — the case that used to throw.
var after = before.Copy();
after.Components =
[
new ComplexFormComponent { ComplexFormEntryId = before.Id, ComponentEntryId = componentB.Id, ComponentHeadword = "b" },
new ComplexFormComponent { ComplexFormEntryId = before.Id, ComponentEntryId = componentA.Id, ComponentHeadword = "a" },
];

await EntrySync.SyncFull(before, after, Api);

(await Api.GetEntry(before.Id)).Should().BeNull();
}
}

public class FwDataEntrySyncTests(ExtraWritingSystemsSyncFixture fixture) : EntrySyncTestsBase(fixture)
Expand Down
27 changes: 26 additions & 1 deletion backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -566,9 +566,34 @@ await fwdataApi.CreateEntry(new Entry()
]
});

//sync may fail because it will try to create a complex form for an entry which was deleted
// Regression: creating the complex-form component for the CRDT-deleted _testEntry once used to throw and wedge the sync.
await _syncService.Sync(crdtApi, fwdataApi, projectSnapshot);

(await crdtApi.GetEntry(_testEntry.Id)).Should().BeNull();
(await fwdataApi.GetEntry(_testEntry.Id)).Should().BeNull();
var crdtComplexForm = await crdtApi.GetEntry(newEntryId);
crdtComplexForm.Should().NotBeNull();
crdtComplexForm.Components.Should().BeEmpty();
(await fwdataApi.GetEntry(newEntryId)).Should().NotBeNull();
}

[Fact]
[Trait("Category", "Integration")]
public async Task EntryEditedInFwDataButDeletedInCrdt_SyncDoesNotThrow()
{
var crdtApi = _fixture.CrdtApi;
var fwdataApi = _fixture.FwDataApi;
await _syncService.Import(crdtApi, fwdataApi);
var projectSnapshot = await _fixture.RegenerateAndGetSnapshot();

await fwdataApi.UpdateEntry(_testEntry.Id, new UpdateObjectInput<Entry>().Set(e => e.CitationForm["en"], "edited"));
await crdtApi.DeleteEntry(_testEntry.Id);

// Regression: the fwdata vs snapshot diff calls SubmitUpdateEntry on the CRDT-deleted entry, which used to throw NotFound.
await _syncService.Sync(crdtApi, fwdataApi, projectSnapshot);

(await crdtApi.GetEntry(_testEntry.Id)).Should().BeNull();
(await fwdataApi.GetEntry(_testEntry.Id)).Should().BeNull();
}

[Fact]
Expand Down
65 changes: 65 additions & 0 deletions backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,71 @@ public Task RemovePublication(Guid entryId, Guid publicationId)
return Task.CompletedTask;
}

#region Submit (sync's result-less write variants)
// Record-only. Overridden explicitly (not inherited from the interface default, which would route to the
// returning Update* and re-read the object) so a dry-run of a conflicted project doesn't throw on the
// now-deleted object.
public Task SubmitUpdateEntry(Guid id, UpdateObjectInput<Entry> update)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}"));
return Task.CompletedTask;
}

public Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput<Sense> update)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}"));
return Task.CompletedTask;
}

public Task SubmitUpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput<ExampleSentence> update)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateExampleSentence), $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}"));
return Task.CompletedTask;
}

public Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? position = null)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateSense), $"Create sense {sense.Gloss}"));
return Task.CompletedTask;
}

public Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}"));
return Task.CompletedTask;
}

public Task SubmitCreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition<ComplexFormComponent>? position = null)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateComplexFormComponent), $"Create complex form component {ComplexFormComponentName(complexFormComponent)}"));
return Task.CompletedTask;
}

public Task SubmitUpdatePartOfSpeech(Guid id, UpdateObjectInput<PartOfSpeech> update)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePartOfSpeech), $"Update part of speech {id}"));
return Task.CompletedTask;
}

public Task SubmitUpdatePublication(Guid id, UpdateObjectInput<Publication> update)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePublication), $"Update publication {id}"));
return Task.CompletedTask;
}

public Task SubmitUpdateSemanticDomain(Guid id, UpdateObjectInput<SemanticDomain> update)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSemanticDomain), $"Update semantic domain {id}"));
return Task.CompletedTask;
}

public Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput<ComplexFormType> update)
{
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateComplexFormType), $"Update complex form type {id}"));
return Task.CompletedTask;
}
#endregion

private string ComplexFormComponentName(ComplexFormComponent? component)
{
if (component == null) return "null";
Expand Down
Loading
Loading