Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
619c640
Add variant model, CRDT changes and API surface
myieye Jul 3, 2026
979f1ad
Wire variants into sync orchestration, import and tests
myieye Jul 4, 2026
deb8129
Add variant fields to viewer entry factories and demo data
myieye Jul 4, 2026
ef20839
Seed standard variant types in the blank project template
myieye Jul 4, 2026
f11fc37
Sync variants FwData<->CRDT: round-trip tests, import fast path, Sena3
myieye Jul 4, 2026
1b38e9b
Update VARIANTS.md status
myieye Jul 4, 2026
b1bdea7
Review fixes: sense-move keeps variant links, hub exposes variant types
myieye Jul 4, 2026
a45f5f7
Demo api parity for the extended MiniLcm JS interface
myieye Jul 4, 2026
3f720ab
Note review outcome and PR links in VARIANTS.md
myieye Jul 4, 2026
be8a6c3
CodeRabbit fixes: sense-ownership guard on variant create, require va…
myieye Jul 4, 2026
9c9c270
Cover entry-level variant reference validation rules
myieye Jul 4, 2026
7879347
Correct VARIANTS.md on type-rename propagation and Comment nullability
myieye Jul 4, 2026
6a18cb4
Match liblcm variant/component semantics found in a deep review
myieye Jul 4, 2026
ebeb384
Harden variant tests: LCM-oracle differential check, HideMinorEntry b…
myieye Jul 4, 2026
7d44dd4
Bring VARIANTS.md status and review log up to date for handoff
myieye Jul 4, 2026
b292156
Review fixes: variant Submit validation gaps, patch guards, naming
myieye Jul 6, 2026
f2fcff4
Round-trip variant type order (review: types are user-ordered in FLEx)
myieye Jul 6, 2026
1978046
Rework variant type ordering to per-item orders (review)
myieye Jul 6, 2026
c7b5d92
Merge remote-tracking branch 'origin/develop' into feat/variants-mode…
myieye Jul 6, 2026
054cf0d
Point VARIANTS.md status at the 3-PR stack
myieye Jul 6, 2026
cf77661
Document Submit* purpose; complete variant Submit normalization
myieye Jul 6, 2026
868dd53
Tolerate concurrently-deleted variant types on the sync path
myieye Jul 7, 2026
49fed9d
Merge remote-tracking branch 'origin/develop' into feat/variants-mode…
myieye Jul 7, 2026
a687970
Merge remote-tracking branch 'origin/develop' into feat/variants-mode…
myieye Jul 7, 2026
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
15 changes: 15 additions & 0 deletions backend/FwLite/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,21 @@ Imperative validation in `MiniLcmApiValidationWrapper` (rules that need an async

---

## `Submit*` write variants (sync fire-and-forget)

`SubmitUpdateX`/`SubmitCreateX` on `IMiniLcmWriteApi` are the **result-less writes the sync's diff-apply uses** instead of the returning `Update`/`Create`. The CRDT submits the change without fetching, so a target concurrently deleted on the other replica is **delete-wins** (no-op) rather than a `NotFoundException` that wedges the whole project sync (see `sync_delete_vs_edit_notfound`). The returning methods fetch-after-write and throw.

**When you need a new `Submit*` variant:** only where the sync diff writes to a target that could be concurrently deleted — every id-targeted update/move, plus child creates inside `EntrySync` whose parent entry may be gone. **Not** for a fresh top-level `Create` of a dependency type (a new GUID has no delete race — that's why there is no `SubmitCreateVariantType`/`ComplexFormType`).

**Wrapper authoring (the trap):** `Submit*` have default interface bodies, so they are **not** compile-enforced like other write methods — a wrapper compiles without handling them, but its default behavior is wrong:

- `MiniLcmApiValidationWrapper` (BeaKona `MemberMatch=Any`): un-overridden `Submit*` forward straight to `_api`, **skipping validation** (the #2362 gap). Override only where an invariant must hold on sync-supplied data (e.g. single-main publication, variant refs).
- `MiniLcmApiWriteNormalizationWrapper` (writes manual): un-overridden `Submit*` hit the default body, which re-routes to the wrapper's own returning `Update`/`Create` and **silently downgrades delete-wins into a throwing update**. Any `Submit*` reachable through this wrapper must be overridden to normalize the payload *and* forward to `_api.Submit*`.

Also fan out to `CrdtMiniLcmApi`, `FwDataMiniLcmApi`, and `DryRunMiniLcmApi` (`SubmitUpdateVariant` is abstract — no id-based twin to forward to). The write-normalization wrapper's coverage is currently variant-complete but not exhaustive across all types; completing it is tracked separately.

---

## Important Files Quick Reference

| File | Purpose | Risk Level |
Expand Down
249 changes: 249 additions & 0 deletions backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/VariantTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
using FwDataMiniLcmBridge.Api;
using FwDataMiniLcmBridge.LcmUtils;
using FwDataMiniLcmBridge.Tests.Fixtures;
using MiniLcm.Models;
using SIL.LCModel;

namespace FwDataMiniLcmBridge.Tests.MiniLcmTests;

[Collection(ProjectLoaderFixture.Name)]
public class VariantTests(ProjectLoaderFixture fixture) : VariantTestsBase
{
protected override Task<IMiniLcmApi> NewApi()
{
return Task.FromResult<IMiniLcmApi>(fixture.NewProjectApi("variant-test", "en", "en"));
}

[Fact]
public async Task GetVariantTypes_IncludesIrregularlyInflectedFormSubtypes()
{
// Plural and Past are children of Irregularly Inflected Form in the possibility
// list; the flattened read must surface them (they're the headline use case)
var types = await Api.GetVariantTypes().ToArrayAsync();
types.Should().Contain(t => t.Id == LexEntryTypeTags.kguidLexTypPluralVar);
types.Should().Contain(t => t.Id == LexEntryTypeTags.kguidLexTypPastVar);
types.Should().Contain(t => t.Id == LexEntryTypeTags.kguidLexTypSpellingVar);
}

[Fact]
public async Task CreateVariantType_LandsAsATopLevelPossibility()
{
// the flattened read can surface children, but created types must be top-level
var created = await Api.CreateVariantType(new VariantType { Id = Guid.NewGuid(), Name = new() { { "en", "custom type" } } });
var fwDataApi = (FwDataMiniLcmApi)BaseApi;
fwDataApi.Cache.LangProject.LexDbOA.VariantEntryTypesOA.PossibilitiesOS
.Should().Contain(p => p.Guid == created.Id);
}

[Fact]
public async Task UpdateVariant_PreservesAMultiBitHideMinorEntry()
{
// LCM reserves HideMinorEntry as a per-publication bitfield; a bool-level no-op
// write must not collapse a multi-bit value to 1
var created = await Api.CreateVariant(Variant.FromEntries(_variantEntry, _mainEntry) with { HideMinorEntry = true });
var fwDataApi = (FwDataMiniLcmApi)BaseApi;
var variantEntry = fwDataApi.EntriesRepository.GetObject(_variantEntryId);
await fwDataApi.Cache.DoUsingNewOrCurrentUOW("Set HideMinorEntry bits",
"Unset HideMinorEntry bits",
() =>
{
variantEntry.VariantEntryRefs.Single().HideMinorEntry = 2;
return ValueTask.CompletedTask;
});

// before deliberately disagrees so the diff emits a HideMinorEntry patch op
await Api.UpdateVariant(created with { HideMinorEntry = false }, created with { HideMinorEntry = true });

variantEntry.VariantEntryRefs.Single().HideMinorEntry.Should().Be(2);
var entry = await Api.GetEntry(_variantEntryId);
entry!.VariantOf.Single().HideMinorEntry.Should().BeTrue();
}
}

/// <summary>
/// tests the per-link fidelity when FwData has multiple variant LexEntryRefs on one entry
/// </summary>
[Collection(ProjectLoaderFixture.Name)]
public class VariantTestsMultipleRefs(ProjectLoaderFixture fixture) : VariantTestsBase
{
protected override Task<IMiniLcmApi> NewApi()
{
return Task.FromResult<IMiniLcmApi>(fixture.NewProjectApi("variant-test-multipleRefs", "en", "en"));
}

public override async Task InitializeAsync()
{
await base.InitializeAsync();
var fwDataApi = (FwDataMiniLcmApi)BaseApi;
var variantEntry = fwDataApi.EntriesRepository.GetObject(_variantEntryId);
await fwDataApi.Cache.DoUsingNewOrCurrentUOW("Add dangling variant LexEntryRefs",
"Remove dangling variant LexEntryRefs",
() =>
{
//FLEx data can contain variant refs with no components; they must not confuse reads or writes
foreach (var _ in Enumerable.Range(0, 2))
{
var entryRef = fwDataApi.Cache.ServiceLocator.GetInstance<ILexEntryRefFactory>().Create();
variantEntry.EntryRefsOS.Add(entryRef);
entryRef.RefType = LexEntryRefTags.krtVariant;
}
return ValueTask.CompletedTask;
});
}

[Fact]
public async Task VariantRefsWithDifferentTypes_ReadAsSeparateLinksWithOwnTypes()
{
var otherMainEntry = await Api.CreateEntry(new()
{
Id = Guid.NewGuid(),
LexemeForm = { { "en", "other main" } }
});
var typeA = await Api.CreateVariantType(new VariantType { Id = Guid.NewGuid(), Name = new() { { "en", "type a" } } });
var typeB = await Api.CreateVariantType(new VariantType { Id = Guid.NewGuid(), Name = new() { { "en", "type b" } } });

await Api.CreateVariant(Variant.FromEntries(_variantEntry, _mainEntry) with { Types = [typeA.ToRef()] });
await Api.CreateVariant(Variant.FromEntries(_variantEntry, otherMainEntry) with { Types = [typeB.ToRef()] });

var entry = await Api.GetEntry(_variantEntryId);
entry!.VariantOf.Should().HaveCount(2);
entry.VariantOf.Should().ContainSingle(v => v.MainEntryId == _mainEntryId)
.Which.Types.Should().ContainSingle(t => t.Id == typeA.Id);
entry.VariantOf.Should().ContainSingle(v => v.MainEntryId == otherMainEntry.Id)
.Which.Types.Should().ContainSingle(t => t.Id == typeB.Id);
}

[Fact]
public async Task DuplicateVariantRefs_AreNotDuplicated()
{
await AddDuplicateVariantRef();

var entry = await Api.GetEntry(_variantEntryId);
entry.Should().NotBeNull();
entry!.VariantOf.Should().HaveCount(1);
var mainEntry = await Api.GetEntry(_mainEntryId);
mainEntry.Should().NotBeNull();
mainEntry!.Variants.Should().HaveCount(1);
}

[Fact]
public async Task DuplicateVariantRefs_BothAreRemoved()
{
await AddDuplicateVariantRef();

var entry = await Api.GetEntry(_variantEntryId);
await Api.DeleteVariant(entry!.VariantOf.Single());

entry = await Api.GetEntry(_variantEntryId);
entry.Should().NotBeNull();
entry!.VariantOf.Should().BeEmpty();
}

private async Task AddDuplicateVariantRef()
{
var fwDataApi = (FwDataMiniLcmApi)BaseApi;
var variantEntry = fwDataApi.EntriesRepository.GetObject(_variantEntryId);
var mainEntry = fwDataApi.EntriesRepository.GetObject(_mainEntryId);
await fwDataApi.Cache.DoUsingNewOrCurrentUOW("Add variant LexEntryRefs",
"Remove variant LexEntryRefs",
() =>
{
variantEntry.MakeVariantOf(mainEntry, fwDataApi.VariantTypesFlattened.First());
variantEntry.MakeVariantOf(mainEntry, fwDataApi.VariantTypesFlattened.Last());
return ValueTask.CompletedTask;
});
}
}

/// <summary>
/// tests per-link edits when FLEx put multiple targets in ONE variant LexEntryRef:
/// Types/HideMinorEntry/Comment live on the ref, shared across all its targets
/// </summary>
[Collection(ProjectLoaderFixture.Name)]
public class VariantTestsSharedRef(ProjectLoaderFixture fixture) : MiniLcmTestBase
{
private readonly Guid _variantEntryId = Guid.NewGuid();
private readonly Guid _mainEntryId = Guid.NewGuid();
private readonly Guid _otherMainEntryId = Guid.NewGuid();

protected override Task<IMiniLcmApi> NewApi()
{
return Task.FromResult<IMiniLcmApi>(fixture.NewProjectApi("variant-test-sharedRef", "en", "en"));
}

public override async Task InitializeAsync()
{
await base.InitializeAsync();
await Api.CreateEntry(new()
{
Id = _variantEntryId,
LexemeForm = { { "en", "variant form" } }
});
await Api.CreateEntry(new()
{
Id = _mainEntryId,
LexemeForm = { { "en", "main entry" } }
});
await Api.CreateEntry(new()
{
Id = _otherMainEntryId,
LexemeForm = { { "en", "other main" } }
});
var fwDataApi = (FwDataMiniLcmApi)BaseApi;
var variantEntry = fwDataApi.EntriesRepository.GetObject(_variantEntryId);
await fwDataApi.Cache.DoUsingNewOrCurrentUOW("Add shared variant LexEntryRef",
"Remove shared variant LexEntryRef",
() =>
{
var entryRef = fwDataApi.Cache.ServiceLocator.GetInstance<ILexEntryRefFactory>().Create();
variantEntry.EntryRefsOS.Add(entryRef);
entryRef.RefType = LexEntryRefTags.krtVariant;
entryRef.HideMinorEntry = 1;
entryRef.VariantEntryTypesRS.Add(fwDataApi.Cache.ServiceLocator
.GetInstance<ILexEntryTypeRepository>()
.GetObject(LexEntryTypeTags.kguidLexTypDialectalVar));
entryRef.ComponentLexemesRS.Add(fwDataApi.EntriesRepository.GetObject(_mainEntryId));
entryRef.ComponentLexemesRS.Add(fwDataApi.EntriesRepository.GetObject(_otherMainEntryId));
return ValueTask.CompletedTask;
});
}

[Fact]
public async Task SharedRef_ReadsAsOneLinkPerTarget()
{
var entry = await Api.GetEntry(_variantEntryId);
entry!.VariantOf.Should().HaveCount(2);
entry.VariantOf.Should().OnlyContain(v => v.HideMinorEntry && v.Types.Count == 1);
}

[Fact]
public async Task AddVariantType_OnASharedRefLink_DoesNotAffectTheSiblingLink()
{
var entry = await Api.GetEntry(_variantEntryId);
var link = entry!.VariantOf.Single(v => v.MainEntryId == _mainEntryId);

await Api.AddVariantType(link, LexEntryTypeTags.kguidLexTypSpellingVar);

entry = await Api.GetEntry(_variantEntryId);
var edited = entry!.VariantOf.Single(v => v.MainEntryId == _mainEntryId);
var sibling = entry.VariantOf.Single(v => v.MainEntryId == _otherMainEntryId);
edited.Types.Select(t => t.Id).Should()
.BeEquivalentTo([LexEntryTypeTags.kguidLexTypDialectalVar, LexEntryTypeTags.kguidLexTypSpellingVar]);
sibling.Types.Select(t => t.Id).Should().BeEquivalentTo([LexEntryTypeTags.kguidLexTypDialectalVar]);
sibling.HideMinorEntry.Should().BeTrue();
}

[Fact]
public async Task RemoveVariantType_OnASharedRefLink_DoesNotAffectTheSiblingLink()
{
var entry = await Api.GetEntry(_variantEntryId);
var link = entry!.VariantOf.Single(v => v.MainEntryId == _mainEntryId);

await Api.RemoveVariantType(link, LexEntryTypeTags.kguidLexTypDialectalVar);

entry = await Api.GetEntry(_variantEntryId);
entry!.VariantOf.Single(v => v.MainEntryId == _mainEntryId).Types.Should().BeEmpty();
entry.VariantOf.Single(v => v.MainEntryId == _otherMainEntryId).Types
.Should().ContainSingle(t => t.Id == LexEntryTypeTags.kguidLexTypDialectalVar);
}
}
Loading
Loading