Skip to content

Commit

Permalink
Reproduce duplicate pos (#1439)
Browse files Browse the repository at this point in the history
* Add failing test for 'UNIQUE constraint failed: PartOfSpeech.Id'

* Ensure new parts of speech have an Id

* Add test to test random orders of adding all our change types

* ensure we only enumerate once.
Use new CrdtConfig property to get change types rather than using reflection

---------

Co-authored-by: Kevin Hahn <[email protected]>
  • Loading branch information
myieye and hahn-kev authored Feb 4, 2025
1 parent c49b457 commit 6af311b
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public void CanRoundTripChanges(IChange change)
[Fact]
public void ChangesIncludesAllValidChangeTypes()
{
var allChangeTypes = LcmCrdtKernel.AllChangeTypes();
var allChangeTypes = LcmCrdtKernel.AllChangeTypes().ToArray();
allChangeTypes.Should().NotBeEmpty();
var testedTypes = Changes().Select(c => c[0].GetType()).ToArray();
using (new AssertionScope())
Expand Down
28 changes: 28 additions & 0 deletions backend/FwLite/LcmCrdt.Tests/Changes/SenseChangeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using SIL.Harmony.Changes;
using LcmCrdt.Changes;

namespace LcmCrdt.Tests.Changes;

public class SenseChangeTests(MiniLcmApiFixture fixture) : IClassFixture<MiniLcmApiFixture>
{
[Fact]
public async Task AddSenseAndUpdatePartOfSpeechInOneCommit()
{
// arrange
var entry = await fixture.Api.CreateEntry(new() { LexemeForm = { { "en", "test entry" } }, });
var partOfSpeech = await fixture.Api.CreatePartOfSpeech(new() { Name = new() { { "en", "test pos" } } });
var sense = new Sense() { Id = Guid.NewGuid(), Gloss = new() { { "en", "test sense" } } };

var createSenseChange = new CreateSenseChange(sense, entry.Id);
var setPartOfSpeechChange = new SetPartOfSpeechChange(sense.Id, partOfSpeech.Id);
List<IChange> changes = [createSenseChange, setPartOfSpeechChange];

// act
await fixture.DataModel.AddChanges(Guid.NewGuid(), changes);

// assert
var actualSense = await fixture.Api.GetSense(entry.Id, sense.Id);
actualSense.Should().NotBeNull();
actualSense.PartOfSpeechId.Should().Be(partOfSpeech.Id);
}
}
165 changes: 165 additions & 0 deletions backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Bogus;
using FluentAssertions.Execution;
using LcmCrdt.Changes;
using LcmCrdt.Changes.Entries;
using SIL.Harmony.Changes;

namespace LcmCrdt.Tests.Changes;

public class UseChangesTests(MiniLcmApiFixture fixture) : IClassFixture<MiniLcmApiFixture>
{

private static readonly Randomizer random = new();
private static readonly Lazy<JsonSerializerOptions> LazyOptions = new(() =>
{
var config = new CrdtConfig();
LcmCrdtKernel.ConfigureCrdt(config);
config.JsonSerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip;
return config.JsonSerializerOptions;
});
private static readonly JsonSerializerOptions Options = LazyOptions.Value;

[Fact]
public async Task CanAddAllChangeTypes()
{
var shuffledChangesWithDependencies = random.Shuffle(GetAllChanges())
.ToDictionary(change => change.Change, change => change.Dependencies == null ? null : random.Shuffle(change.Dependencies).ToList());
var pendingChanges = shuffledChangesWithDependencies.Select(c => c.Key).ToList();
var queuedChanges = new List<IChange>(pendingChanges.Count);

while (pendingChanges is not [])
{
var change = FindFirstSatisfiedChangeRecursive(pendingChanges, shuffledChangesWithDependencies, queuedChanges);
if (!pendingChanges.Remove(change)) throw new InvalidOperationException("Change not found in pending changes");
queuedChanges.Add(change);
}

try
{
await fixture.DataModel.AddChanges(Guid.NewGuid(), queuedChanges);
}
catch (Exception e)
{
var serializedQueuedChanges = JsonSerializer.Serialize(queuedChanges, Options);
throw new Exception($"Failed to add changes: {e.Message}. JSON:\n\n{serializedQueuedChanges}", e);
}

}

private bool AreSatisfied([NotNullWhen(false)] IEnumerable<IChange>? dependencies, IEnumerable<IChange> queuedChanges)
{
return dependencies == null || dependencies.All(queuedChanges.Contains);
}

private IChange FindFirstSatisfiedChangeRecursive(List<IChange> changes,
Dictionary<IChange, List<IChange>?> allChangesWithDependencies,
List<IChange> queuedChanges)
{
var change = changes.First(d => !queuedChanges.Contains(d));
var dependencies = allChangesWithDependencies[change];
if (AreSatisfied(dependencies, queuedChanges))
{
return change;
}
else
{
return FindFirstSatisfiedChangeRecursive(dependencies, allChangesWithDependencies, queuedChanges);
}
}

[Fact]
public void ChangesIncludeAllExpectedChangeTypes()
{
var allExpectedChangeTypes = LcmCrdtKernel.AllChangeTypes()
.Where(type => !type.Name.StartsWith("DeleteChange`") && !type.Name.StartsWith("JsonPatchChange`")).ToArray();
allExpectedChangeTypes.Should().NotBeEmpty();

var testedTypes = GetAllChanges().Select(c => c.Change.GetType()).ToArray();
using (new AssertionScope())
{
foreach (var allChangeType in allExpectedChangeTypes)
{
testedTypes.Should().Contain(allChangeType);
}
}
}

private record ChangeWithDependencies(IChange Change, IEnumerable<IChange>? Dependencies = null);

private static IEnumerable<ChangeWithDependencies> GetAllChanges()
{
var entry = new Entry { Id = Guid.NewGuid(), LexemeForm = { { "en", "test entry" } } };
var createEntryChange = new CreateEntryChange(entry);
yield return new ChangeWithDependencies(createEntryChange);

var partOfSpeech = new PartOfSpeech { Id = Guid.NewGuid(), Name = { { "en", "test pos" } } };
var createPartOfSpeechChange = new CreatePartOfSpeechChange(partOfSpeech.Id, partOfSpeech.Name);
yield return new ChangeWithDependencies(createPartOfSpeechChange);

var sense = new Sense { Id = Guid.NewGuid(), Gloss = { { "en", "test sense" } } };
var createSenseChange = new CreateSenseChange(sense, entry.Id);
yield return new ChangeWithDependencies(createSenseChange, [createEntryChange]);

var exampleSentence = new ExampleSentence { Id = Guid.NewGuid(), Sentence = new MultiString { { "en", "test sentence" } } };
var createExampleSentenceChange = new CreateExampleSentenceChange(exampleSentence, sense.Id);
yield return new ChangeWithDependencies(createExampleSentenceChange, [createSenseChange]);

var semanticDomain = new SemanticDomain { Id = Guid.NewGuid(), Name = { { "en", "test sd" } } };
var createSemanticDomainChange = new CreateSemanticDomainChange(semanticDomain.Id, semanticDomain.Name, "1.1.1");
yield return new ChangeWithDependencies(createSemanticDomainChange);

var writingSystem = new WritingSystem { Id = Guid.NewGuid(), WsId = "de", Name = "test ws", Abbreviation = "tws", Font = "Arial", Type = WritingSystemType.Vernacular };
var createWritingSystemChange = new CreateWritingSystemChange(writingSystem, writingSystem.Type, writingSystem.Id, 0);
yield return new ChangeWithDependencies(createWritingSystemChange);

var complexFormTypeName = new MultiString { { "en", "test cft" } };
var complexFormType = new ComplexFormType { Id = Guid.NewGuid(), Name = complexFormTypeName };
var createComplexFormType = new CreateComplexFormType(complexFormType.Id, complexFormTypeName);
yield return new ChangeWithDependencies(createComplexFormType);

var complexFormEntry = new Entry { Id = Guid.NewGuid(), LexemeForm = { { "en", "test complex form" } } };
var createComplexFormEntryChange = new CreateEntryChange(complexFormEntry);
yield return new ChangeWithDependencies(createComplexFormEntryChange);

var complexFormComponent = ComplexFormComponent.FromEntries(complexFormEntry, entry, sense.Id);
var createComplexFormComponentChange = new AddEntryComponentChange(complexFormComponent);
yield return new ChangeWithDependencies(createComplexFormComponentChange, [createComplexFormEntryChange, createEntryChange, createSenseChange]);

var setPartOfSpeechChange = new SetPartOfSpeechChange(sense.Id, partOfSpeech.Id);
yield return new ChangeWithDependencies(setPartOfSpeechChange, [createSenseChange, createPartOfSpeechChange]);

var addSemanticDomainChange = new AddSemanticDomainChange(semanticDomain, sense.Id);
yield return new ChangeWithDependencies(addSemanticDomainChange, [createSenseChange, createSemanticDomainChange]);

var semanticDomain2 = new SemanticDomain { Id = Guid.NewGuid(), Name = { { "en", "sd 2" } } };
var addSemanticDomain2Change = new CreateSemanticDomainChange(semanticDomain2.Id, semanticDomain2.Name, "1.1.2");
yield return new ChangeWithDependencies(addSemanticDomain2Change);

var replaceSemanticDomainChange = new ReplaceSemanticDomainChange(semanticDomain.Id, semanticDomain2, sense.Id);
yield return new ChangeWithDependencies(replaceSemanticDomainChange, [addSemanticDomain2Change, addSemanticDomainChange]);

var removeSemanticDomainChange = new RemoveSemanticDomainChange(semanticDomain2.Id, sense.Id);
yield return new ChangeWithDependencies(removeSemanticDomainChange, [replaceSemanticDomainChange]);

var addComplexFormTypeChange = new AddComplexFormTypeChange(entry.Id, complexFormType);
yield return new ChangeWithDependencies(addComplexFormTypeChange, [createComplexFormType, createEntryChange]);

var removeComplexFormTypeChange = new RemoveComplexFormTypeChange(entry.Id, complexFormType.Id);
yield return new ChangeWithDependencies(removeComplexFormTypeChange, [addComplexFormTypeChange]);

var componentEntry = new Entry { Id = Guid.NewGuid(), LexemeForm = { { "en", "test component" } } };
var createcomponentEntryChange = new CreateEntryChange(componentEntry);
yield return new ChangeWithDependencies(createcomponentEntryChange);

var setComplexFormTypeChange = SetComplexFormComponentChange.NewComponent(complexFormComponent.Id, componentEntry.Id);
yield return new ChangeWithDependencies(setComplexFormTypeChange, [createcomponentEntryChange, createComplexFormComponentChange]);

var setSenseOrderChange = new LcmCrdt.Changes.SetOrderChange<Sense>(sense.Id, 10);
yield return new ChangeWithDependencies(setSenseOrderChange, [createSenseChange]);

var setExampleSentenceOrderChange = new LcmCrdt.Changes.SetOrderChange<ExampleSentence>(exampleSentence.Id, 10);
yield return new ChangeWithDependencies(setExampleSentenceOrderChange, [createExampleSentenceChange]);
}
}
1 change: 1 addition & 0 deletions backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ public IAsyncEnumerable<PartOfSpeech> GetPartsOfSpeech()

public async Task<PartOfSpeech> CreatePartOfSpeech(PartOfSpeech partOfSpeech)
{
if (partOfSpeech.Id == Guid.Empty) partOfSpeech.Id = Guid.NewGuid();
await validators.ValidateAndThrow(partOfSpeech);
await AddChange(new CreatePartOfSpeechChange(partOfSpeech.Id, partOfSpeech.Name, partOfSpeech.Predefined));
return await GetPartOfSpeech(partOfSpeech.Id) ?? throw new NullReferenceException();
Expand Down
8 changes: 2 additions & 6 deletions backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,11 @@ public static void ConfigureCrdt(CrdtConfig config)
;
}

public static Type[] AllChangeTypes()
public static IEnumerable<Type> AllChangeTypes()
{
var crdtConfig = new CrdtConfig();
ConfigureCrdt(crdtConfig);


var list = typeof(ChangeTypeListBuilder).GetProperty("Types", BindingFlags.Instance | BindingFlags.NonPublic)
?.GetValue(crdtConfig.ChangeTypeListBuilder) as List<JsonDerivedType>;
return list?.Select(t => t.DerivedType).ToArray() ?? [];
return crdtConfig.ChangeTypes;
}


Expand Down

0 comments on commit 6af311b

Please sign in to comment.