Skip to content

Commit 030069c

Browse files
myieyehahn-kev
andauthored
Fix referencing added entries in sync (#1594)
* Fix referencing added entries in sync * write a test for the changes made to DiffAddThenUpdate --------- Co-authored-by: Kevin Hahn <[email protected]>
1 parent f6a47d0 commit 030069c

File tree

4 files changed

+233
-73
lines changed

4 files changed

+233
-73
lines changed

backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs

Lines changed: 100 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
namespace FwLiteProjectSync.Tests;
1010

11-
public class EntrySyncTests : IClassFixture<SyncFixture>, IAsyncLifetime
11+
public class CrdtEntrySyncTests(SyncFixture fixture) : EntrySyncTestsBase(fixture)
1212
{
1313
private static readonly AutoFaker AutoFaker = new(new AutoFakerConfig()
1414
{
@@ -22,37 +22,25 @@ public class EntrySyncTests : IClassFixture<SyncFixture>, IAsyncLifetime
2222
]
2323
});
2424

25-
public EntrySyncTests(SyncFixture fixture)
25+
protected override IMiniLcmApi GetApi(SyncFixture fixture)
2626
{
27-
_fixture = fixture;
27+
return fixture.CrdtApi;
2828
}
2929

30-
public async Task InitializeAsync()
31-
{
32-
await _fixture.EnsureDefaultVernacularWritingSystemExistsInCrdt();
33-
}
34-
35-
public Task DisposeAsync()
36-
{
37-
return Task.CompletedTask;
38-
}
39-
40-
private readonly SyncFixture _fixture;
41-
4230
[Fact]
4331
public async Task CanSyncRandomEntries()
4432
{
45-
var createdEntry = await _fixture.CrdtApi.CreateEntry(await AutoFaker.EntryReadyForCreation(_fixture.CrdtApi));
46-
var after = await AutoFaker.EntryReadyForCreation(_fixture.CrdtApi, entryId: createdEntry.Id);
33+
var createdEntry = await Api.CreateEntry(await AutoFaker.EntryReadyForCreation(Api));
34+
var after = await AutoFaker.EntryReadyForCreation(Api, entryId: createdEntry.Id);
4735

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

54-
await EntrySync.Sync(createdEntry, after, _fixture.CrdtApi);
55-
var actual = await _fixture.CrdtApi.GetEntry(after.Id);
42+
await EntrySync.Sync(createdEntry, after, Api);
43+
var actual = await Api.GetEntry(after.Id);
5644
actual.Should().NotBeNull();
5745
actual.Should().BeEquivalentTo(after, options => options
5846
.For(e => e.Senses).Exclude(s => s.Order)
@@ -61,14 +49,41 @@ public async Task CanSyncRandomEntries()
6149
.For(e => e.Senses).For(s => s.ExampleSentences).Exclude(e => e.Order)
6250
);
6351
}
52+
}
53+
54+
public class FwDataEntrySyncTests(SyncFixture fixture) : EntrySyncTestsBase(fixture)
55+
{
56+
protected override IMiniLcmApi GetApi(SyncFixture fixture)
57+
{
58+
return fixture.FwDataApi;
59+
}
60+
}
61+
62+
public abstract class EntrySyncTestsBase(SyncFixture fixture) : IClassFixture<SyncFixture>, IAsyncLifetime
63+
{
64+
public async Task InitializeAsync()
65+
{
66+
await _fixture.EnsureDefaultVernacularWritingSystemExistsInCrdt();
67+
Api = GetApi(_fixture);
68+
}
69+
70+
public Task DisposeAsync()
71+
{
72+
return Task.CompletedTask;
73+
}
74+
75+
protected abstract IMiniLcmApi GetApi(SyncFixture fixture);
76+
77+
private readonly SyncFixture _fixture = fixture;
78+
protected IMiniLcmApi Api = null!;
6479

6580
[Fact]
6681
public async Task CanChangeComplexFormViaSync_Components()
6782
{
68-
var component1 = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "component1" } } });
69-
var component2 = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "component2" } } });
83+
var component1 = await Api.CreateEntry(new() { LexemeForm = { { "en", "component1" } } });
84+
var component2 = await Api.CreateEntry(new() { LexemeForm = { { "en", "component2" } } });
7085
var complexFormId = Guid.NewGuid();
71-
var complexForm = await _fixture.CrdtApi.CreateEntry(new()
86+
var complexForm = await Api.CreateEntry(new()
7287
{
7388
Id = complexFormId,
7489
LexemeForm = { { "en", "complex form" } },
@@ -83,24 +98,25 @@ public async Task CanChangeComplexFormViaSync_Components()
8398
}
8499
]
85100
});
86-
Entry after = (Entry) complexForm.Copy();
87-
after.Components[0].ComponentEntryId = component2.Id;
88-
after.Components[0].ComponentHeadword = component2.Headword();
101+
var complexFormAfter = complexForm.Copy();
102+
complexFormAfter.Components[0].ComponentEntryId = component2.Id;
103+
complexFormAfter.Components[0].ComponentHeadword = component2.Headword();
89104

90-
await EntrySync.Sync(complexForm, after, _fixture.CrdtApi);
105+
await EntrySync.Sync(complexForm, complexFormAfter, Api);
91106

92-
var actual = await _fixture.CrdtApi.GetEntry(after.Id);
107+
var actual = await Api.GetEntry(complexFormAfter.Id);
93108
actual.Should().NotBeNull();
94-
actual.Should().BeEquivalentTo(after, options => options);
109+
actual.Should().BeEquivalentTo(complexFormAfter, options => options
110+
.For(e => e.Components).Exclude(c => c.Id));
95111
}
96112

97113
[Fact]
98114
public async Task CanChangeComplexFormViaSync_ComplexForms()
99115
{
100-
var complexForm1 = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "complexForm1" } } });
101-
var complexForm2 = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "complexForm2" } } });
116+
var complexForm1 = await Api.CreateEntry(new() { LexemeForm = { { "en", "complexForm1" } } });
117+
var complexForm2 = await Api.CreateEntry(new() { LexemeForm = { { "en", "complexForm2" } } });
102118
var componentId = Guid.NewGuid();
103-
var component = await _fixture.CrdtApi.CreateEntry(new()
119+
var component = await Api.CreateEntry(new()
104120
{
105121
Id = componentId,
106122
LexemeForm = { { "en", "component" } },
@@ -115,47 +131,44 @@ public async Task CanChangeComplexFormViaSync_ComplexForms()
115131
}
116132
]
117133
});
118-
Entry after = (Entry) component.Copy();
119-
after.ComplexForms[0].ComplexFormEntryId = complexForm2.Id;
120-
after.ComplexForms[0].ComplexFormHeadword = complexForm2.Headword();
134+
var componentAter = component.Copy();
135+
componentAter.ComplexForms[0].ComplexFormEntryId = complexForm2.Id;
136+
componentAter.ComplexForms[0].ComplexFormHeadword = complexForm2.Headword();
121137

122-
await EntrySync.Sync(component, after, _fixture.CrdtApi);
138+
await EntrySync.Sync(component, componentAter, Api);
123139

124-
var actual = await _fixture.CrdtApi.GetEntry(after.Id);
140+
var actual = await Api.GetEntry(componentAter.Id);
125141
actual.Should().NotBeNull();
126-
actual.Should().BeEquivalentTo(after, options => options);
142+
actual.Should().BeEquivalentTo(componentAter, options => options
143+
.For(e => e.ComplexForms).Exclude(c => c.Id));
127144
}
128145

129146
[Fact]
130147
public async Task CanChangeComplexFormTypeViaSync()
131148
{
132-
var complexFormType = await _fixture.CrdtApi.CreateComplexFormType(new() { Name = new() { { "en", "complexFormType" } } });
133-
var entry = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "complexForm1" } } });
134-
var after = (Entry) entry.Copy();
149+
var complexFormType = await Api.CreateComplexFormType(new() { Name = new() { { "en", "complexFormType" } } });
150+
var entry = await Api.CreateEntry(new() { LexemeForm = { { "en", "complexForm1" } } });
151+
var after = entry.Copy();
135152
after.ComplexFormTypes = [complexFormType];
136-
await EntrySync.Sync(entry, after, _fixture.CrdtApi);
153+
await EntrySync.Sync(entry, after, Api);
137154

138-
var actual = await _fixture.CrdtApi.GetEntry(after.Id);
155+
var actual = await Api.GetEntry(after.Id);
139156
actual.Should().NotBeNull();
140157
actual.Should().BeEquivalentTo(after, options => options);
141158
}
142159

143160
[Theory]
144-
[InlineData(true, ProjectDataFormat.Harmony)]
145-
[InlineData(true, ProjectDataFormat.FwData)]
146-
[InlineData(false, ProjectDataFormat.Harmony)]
147-
[InlineData(false, ProjectDataFormat.FwData)]
148-
public async Task CanInsertComplexFormComponentViaSync(bool componentThenComplexForm, ProjectDataFormat apiType)
161+
[InlineData(true)]
162+
[InlineData(false)]
163+
public async Task CanInsertComplexFormComponentViaSync(bool componentThenComplexForm)
149164
{
150165
// arrange
151-
IMiniLcmApi api = apiType == ProjectDataFormat.Harmony ? _fixture.CrdtApi : _fixture.FwDataApi;
152-
153-
var existingComponent = await api.CreateEntry(new() { LexemeForm = { { "en", "existing-component" } } });
154-
var complexFormBefore = await api.CreateEntry(new() { LexemeForm = { { "en", "complex-form" } } });
155-
await api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(complexFormBefore, existingComponent));
156-
complexFormBefore = (await api.GetEntry(complexFormBefore.Id))!;
166+
var existingComponent = await Api.CreateEntry(new() { LexemeForm = { { "en", "existing-component" } } });
167+
var complexFormBefore = await Api.CreateEntry(new() { LexemeForm = { { "en", "complex-form" } } });
168+
await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(complexFormBefore, existingComponent));
169+
complexFormBefore = (await Api.GetEntry(complexFormBefore.Id))!;
157170

158-
var newComponentBefore = await api.CreateEntry(new() { LexemeForm = { { "en", "component" } } });
171+
var newComponentBefore = await Api.CreateEntry(new() { LexemeForm = { { "en", "component" } } });
159172
var newComplexFormComponent = ComplexFormComponent.FromEntries(complexFormBefore, newComponentBefore);
160173

161174
var newComponentAfter = newComponentBefore.Copy();
@@ -169,18 +182,18 @@ public async Task CanInsertComplexFormComponentViaSync(bool componentThenComplex
169182
// this results in 2 crdt changes:
170183
// (1) add complex-form (i.e. implicitly add component)
171184
// (2) move component to the right place
172-
await EntrySync.Sync([newComponentBefore, complexFormBefore], [newComponentAfter, complexFormAfter], api);
185+
await EntrySync.Sync([newComponentBefore, complexFormBefore], [newComponentAfter, complexFormAfter], Api);
173186
}
174187
else
175188
{
176189
// this results in 1 crdt change:
177190
// the component is added in the right place
178191
// (adding the complex-form becomes a no-op, because it already exists and a BetweenPosition is not specified)
179-
await EntrySync.Sync([complexFormBefore, newComponentBefore], [complexFormAfter, newComponentAfter], api);
192+
await EntrySync.Sync([complexFormBefore, newComponentBefore], [complexFormAfter, newComponentAfter], Api);
180193
}
181194

182195
// assert
183-
var actual = await api.GetEntry(complexFormAfter.Id);
196+
var actual = await Api.GetEntry(complexFormAfter.Id);
184197
actual.Should().NotBeNull();
185198
actual.Should().BeEquivalentTo(complexFormAfter, options => options
186199
.WithStrictOrdering()
@@ -191,4 +204,34 @@ public async Task CanInsertComplexFormComponentViaSync(bool componentThenComplex
191204
.Excluding(c => c.Order)
192205
.Excluding(c => c.Id));
193206
}
207+
208+
[Fact]
209+
public async Task CanSyncNewEntryReferencedByExistingEntry()
210+
{
211+
// arrange
212+
// - before
213+
var existingEntryBefore = await Api.CreateEntry(new() { LexemeForm = { { "en", "existing-component" } } });
214+
215+
// - after
216+
var existingEntryAfter = existingEntryBefore.Copy();
217+
var newEntry = new Entry() { Id = Guid.NewGuid(), LexemeForm = { { "en", "complex-form" } } };
218+
var newComplexFormComponent = ComplexFormComponent.FromEntries(newEntry, existingEntryAfter);
219+
existingEntryAfter.ComplexForms.Add(newComplexFormComponent);
220+
newEntry.Components.Add(newComplexFormComponent);
221+
222+
// act
223+
await EntrySync.Sync([existingEntryBefore], [existingEntryAfter, newEntry], Api);
224+
225+
// assert
226+
var actualExistingEntry = await Api.GetEntry(existingEntryAfter.Id);
227+
actualExistingEntry.Should().BeEquivalentTo(existingEntryAfter, options => options
228+
.For(e => e.ComplexForms).Exclude(c => c.Id)
229+
.For(e => e.ComplexForms).Exclude(c => c.Order));
230+
231+
var actualNewEntry = await Api.GetEntry(newEntry.Id);
232+
actualNewEntry.Should().BeEquivalentTo(newEntry, options => options
233+
.Excluding(e => e.ComplexFormTypes) // LibLcm automatically creates a complex form type. Should we?
234+
.For(e => e.Components).Exclude(c => c.Id)
235+
.For(e => e.Components).Exclude(c => c.Order));
236+
}
194237
}

backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public void DeleteSyncSnapshot()
8585
if (File.Exists(snapshotPath)) File.Delete(snapshotPath);
8686
}
8787

88-
private static readonly SemaphoreSlim _vernacularSemaphore = new(1, 1);
88+
private readonly SemaphoreSlim _vernacularSemaphore = new(1, 1);
8989

9090
// a vernacular writing system is required in order to query for entries
9191
// this is optional setup, because our core sync integration tests benefit from having a CRDT project that's as empty as possible

backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Runtime.CompilerServices;
12
using MiniLcm.SyncHelpers;
3+
using Moq;
24

35
namespace MiniLcm.Tests;
46

@@ -192,6 +194,121 @@ private static CollectionDiffOperation Remove(TestOrderable value)
192194
{
193195
return new CollectionDiffOperation(value, PositionDiffKind.Remove);
194196
}
197+
198+
199+
public record Entry(Guid Id, string Word);
200+
201+
private readonly FakeDiffApi _fakeApi = new();
202+
203+
[Fact]
204+
public async Task Diff_CallsAddForNewRecords()
205+
{
206+
var entry = new Entry(Guid.NewGuid(), "test");
207+
await DiffCollection.Diff([], [entry], _fakeApi);
208+
_fakeApi.VerifyCalls(new FakeDiffApi.MethodCall(entry, nameof(FakeDiffApi.Add)));
209+
}
210+
211+
[Fact]
212+
public async Task Diff_CallsRemoveForMissingRecords()
213+
{
214+
var entry = new Entry(Guid.NewGuid(), "test");
215+
await DiffCollection.Diff([entry], [], _fakeApi);
216+
_fakeApi.VerifyCalls(new FakeDiffApi.MethodCall(entry, nameof(FakeDiffApi.Remove)));
217+
}
218+
219+
[Fact]
220+
public async Task Diff_CallsReplaceForMatchingRecords()
221+
{
222+
var entry = new Entry(Guid.NewGuid(), "test");
223+
var updated = entry with { Word = "new" };
224+
await DiffCollection.Diff([entry], [updated], _fakeApi);
225+
_fakeApi.VerifyCalls(new FakeDiffApi.MethodCall((entry, updated), nameof(FakeDiffApi.Replace)));
226+
}
227+
228+
[Fact]
229+
public async Task Diff_AddThenUpdate_CallsAddForNewRecords()
230+
{
231+
var entry = new Entry(Guid.NewGuid(), "test");
232+
await DiffCollection.DiffAddThenUpdate([], [entry], _fakeApi);
233+
_fakeApi.VerifyCalls(
234+
new FakeDiffApi.MethodCall(entry, nameof(FakeDiffApi.AddAndGet)),
235+
new FakeDiffApi.MethodCall((entry, entry), nameof(FakeDiffApi.Replace))
236+
);
237+
}
238+
239+
[Fact]
240+
public async Task DiffAddThenUpdate_CallsRemoveForMissingRecords()
241+
{
242+
var entry = new Entry(Guid.NewGuid(), "test");
243+
await DiffCollection.DiffAddThenUpdate([entry], [], _fakeApi);
244+
_fakeApi.VerifyCalls(new FakeDiffApi.MethodCall(entry, nameof(FakeDiffApi.Remove)));
245+
}
246+
247+
[Fact]
248+
public async Task DiffAddThenUpdate_CallsReplaceForMatchingRecords()
249+
{
250+
var entry = new Entry(Guid.NewGuid(), "test");
251+
var updated = entry with { Word = "new" };
252+
await DiffCollection.DiffAddThenUpdate([entry], [updated], _fakeApi);
253+
_fakeApi.VerifyCalls(new FakeDiffApi.MethodCall((entry, updated), nameof(FakeDiffApi.Replace)));
254+
}
255+
256+
[Fact]
257+
public async Task DiffAddThenUpdate_AddAlwaysBeforeReplace()
258+
{
259+
var newEntry = new Entry(Guid.NewGuid(), "new");
260+
var oldEntry = new Entry(Guid.NewGuid(), "test");
261+
var updated = oldEntry with { Word = "new" };
262+
await DiffCollection.DiffAddThenUpdate([oldEntry], [updated, newEntry], _fakeApi);
263+
//this order is required because the new entry must be created before the updated entry is modified.
264+
//the updated entry might reference the newEntry and so must be updated after the new entry is created.
265+
//the order that the replace calls are made is unimportant.
266+
_fakeApi.VerifyCalls(
267+
new FakeDiffApi.MethodCall(newEntry, nameof(FakeDiffApi.AddAndGet)),
268+
new FakeDiffApi.MethodCall((oldEntry, updated), nameof(FakeDiffApi.Replace)),
269+
new FakeDiffApi.MethodCall((newEntry, newEntry), nameof(FakeDiffApi.Replace))
270+
);
271+
}
272+
273+
private class FakeDiffApi: CollectionDiffApi<Entry, Guid>
274+
{
275+
public record MethodCall(object Args, [CallerMemberName] string Name = "");
276+
277+
public List<MethodCall> Calls { get; set; } = [];
278+
public override Task<int> Add(Entry value)
279+
{
280+
Calls.Add(new(value));
281+
return Task.FromResult(1);
282+
}
283+
284+
public override Task<int> Remove(Entry value)
285+
{
286+
Calls.Add(new(value));
287+
return Task.FromResult(1);
288+
}
289+
290+
public override Task<int> Replace(Entry before, Entry after)
291+
{
292+
Calls.Add(new((before, after)));
293+
return Task.FromResult(1);
294+
}
295+
296+
public override Task<(int, Entry)> AddAndGet(Entry value)
297+
{
298+
Calls.Add(new(value));
299+
return Task.FromResult((1, value));
300+
}
301+
302+
public override Guid GetId(Entry value)
303+
{
304+
return value.Id;
305+
}
306+
307+
public void VerifyCalls(params MethodCall[] expectedCalls)
308+
{
309+
Calls.Should().BeEquivalentTo(expectedCalls, o => o.WithStrictOrdering());
310+
}
311+
}
195312
}
196313

197314
public class CollectionDiffTestCase

0 commit comments

Comments
 (0)