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
6 changes: 3 additions & 3 deletions backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,9 @@
<PackageVersion Include="SIL.Chorus.LibChorus" Version="6.0.0-beta0064" />
<PackageVersion Include="SIL.Chorus.Mercurial" Version="6.5.1.43" />
<PackageVersion Include="SIL.ChorusPlugin.LfMergeBridge" Version="4.3.0-beta0048" />
<PackageVersion Include="SIL.Harmony" Version="0.2.1-rc.211" />
<PackageVersion Include="SIL.Harmony.Core" Version="0.2.1-rc.211" />
<PackageVersion Include="SIL.Harmony.Linq2db" Version="0.2.1-rc.211" />
<PackageVersion Include="SIL.Harmony" Version="0.2.1-rc.225" />
<PackageVersion Include="SIL.Harmony.Core" Version="0.2.1-rc.225" />
<PackageVersion Include="SIL.Harmony.Linq2db" Version="0.2.1-rc.225" />
<PackageVersion Include="SIL.Core" Version="17.0.0" />
<PackageVersion Include="SIL.LCModel" Version="11.0.0-beta0165" />
<PackageVersion Include="SIL.LCModel.FixData" Version="11.0.0-beta0165" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ public Task<IObjectWithId> GetObject(Guid commitId, Guid entityId)
}

[JSInvokable]
public async ValueTask<ProjectActivity[]> ProjectActivity(
public async Task<ProjectActivity[]> ProjectActivity(
int skip,
int take,
string[]? authorFilterKeys = null,
string[]? changeTypeKeys = null,
ActivitySort sort = ActivitySort.NewestFirst)
{
return await historyService.ProjectActivity(skip, take,
Comment thread
myieye marked this conversation as resolved.
new ActivityQuery(authorFilterKeys, changeTypeKeys, sort)).ToArrayAsync();
new ActivityQuery(authorFilterKeys, changeTypeKeys, sort));
}

[JSInvokable]
Expand Down
14 changes: 14 additions & 0 deletions backend/FwLite/FwLiteShared/TypeGen/ChangeTypeMarker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Namespace is LcmCrdt (not FwLiteShared.TypeGen) on purpose: Reinforced.Typings derives the generated
// file's folder from the type's namespace and the file name from the type name, so this places the output
// at generated-types/LcmCrdt/ChangeType.ts. Named to match the emitted union so properties can reference it.
#pragma warning disable IDE0130 // Namespace does not match folder structure
namespace LcmCrdt;
#pragma warning restore IDE0130 // Namespace does not match folder structure


/// <summary>
/// Marker type only — no members. Reinforced.Typings (via <c>ChangeTypesCodeGenerator</c>) replaces it with a
/// <c>ChangeType</c> string-literal union plus a <c>knownChangeTypes</c> array built from the registered change
/// types, so the frontend has a generated, exhaustive list of change <c>$type</c> values.
/// </summary>
internal sealed class ChangeType;
33 changes: 33 additions & 0 deletions backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using LcmCrdt;
using Reinforced.Typings;
using Reinforced.Typings.Ast;
using Reinforced.Typings.Generators;

namespace FwLiteShared.TypeGen;

/// <summary>
/// Emits a `ChangeType` string-literal union and a `knownChangeTypes` array built from the registered CRDT
/// change types (<see cref="LcmCrdtKernel.AllChangeTypes"/>) and each type's serialized <c>$type</c>
/// discriminator (<see cref="HistoryService.GetChangeTypeKeyFromType"/>). Attached to the <c>ChangeType</c>
/// marker; suppresses the marker's own output.
/// </summary>
public class ChangeTypesCodeGenerator : ClassCodeGenerator
{
public override RtClass GenerateNode(Type element, RtClass result, TypeResolver resolver)
{
var typeNames = LcmCrdtKernel.AllChangeTypes()
.Select(HistoryService.GetChangeTypeKeyFromType)
.Distinct()
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();

var union = string.Concat(typeNames.Select(name => $"\n | '{name}'"));
var array = string.Concat(typeNames.Select(name => $"\n '{name}',"));

Context.Location.CurrentNamespace.CompilationUnits.Add(new RtRaw(
$"export type ChangeType ={union};\n\nexport const knownChangeTypes = [{array}\n] as const satisfies readonly ChangeType[];\n"));
Comment thread
myieye marked this conversation as resolved.

// Suppress the marker class; only the raw union/array above is emitted.
return null!;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(FwLiteConfig),
typeof(HistoryLineItem),
typeof(ProjectActivity),
typeof(ActivityChange),
typeof(ActivityChangeInfo),
typeof(ActivityAuthor),
typeof(ActivityChangeType),
typeof(ActivityQuery),
Expand All @@ -198,6 +200,12 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(HarmonyResource<LcmFileMetadata>),
], exportBuilder => exportBuilder.WithPublicProperties());

builder.ExportAsClass<ChangeType>().WithCodeGenerator<ChangeTypesCodeGenerator>();
builder.ExportAsInterface<ActivityChangeType>()
.WithProperty(t => t.Key, p => p.Type<ChangeType>());
builder.ExportAsInterface<ActivityQuery>()
.WithProperty(q => q.ChangeTypeKeys, p => p.Type<ChangeType[]>());

builder.ExportAsEnum<ActivitySort>().UseString();
builder.ExportAsEnum<FwEventType>().UseString();
builder.ExportAsEnum<LogLevel>().UseString(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using FluentAssertions.Execution;
using LcmCrdt.Tests.Changes;

namespace LcmCrdt.Tests;

public class ActivityChangeInfoResolverCoverageTests : HistoryServiceActivityTestsBase
{
// Entity types for which a "subject" is not currently supported
private static readonly Dictionary<string, string> IntentionallyDegraded = new()
{
["RemoteResource"] = "Media upload plumbing (Harmony type, not IObjectWithId); the filename lives on " +
"the resource service, not a projected table, and only one of its change types carries it inline.",
};

[Fact]
public async Task EveryChangeEntityType_ResolvesASubject_OrIsIntentionallyDegraded()
{
await DataModel.AddChanges(ClientId, UseChangesTests.AllChangesInDependencyOrder());
var activities = await Service.ProjectActivity(0, take: 1000, new ActivityQuery());

// entity type name -> did EVERY change on that type resolve a non-null Subject (coverage), and did ANY
// (degraded-allowlist rot-check). The catalogue populates a displayable label on every object, so a
// handled type should resolve for all of its changes, not just one.
var allResolved = new Dictionary<string, bool>();
var anyResolved = new Dictionary<string, bool>();
foreach (var activity in activities)
{
foreach (var change in activity.Changes)
{
var name = EntityTypeName(change.Entity.Change.EntityType);
var hasSubject = change.Info.Subject is not null;
allResolved[name] = allResolved.GetValueOrDefault(name, true) && hasSubject;
anyResolved[name] = anyResolved.GetValueOrDefault(name) || hasSubject;
}
}

using var _ = new AssertionScope();
foreach (var (name, resolved) in allResolved)
{
if (IntentionallyDegraded.ContainsKey(name)) continue;
resolved.Should().BeTrue($"a change on entity type '{name}' resolved no Subject in the activity feed; " +
"give it a Subject arm in ActivityChangeInfoResolver (with an example test) " +
$"or list it in {nameof(IntentionallyDegraded)} with a reason");
}

// Keep the allowlist from rotting: each listed type must actually be exercised, and must genuinely
// degrade. If ANY change on it starts resolving a Subject, it's no longer fully degraded — drop it from
// the list (a type typically gains resolution one change at a time, so check "any", not "all").
foreach (var name in IntentionallyDegraded.Keys)
{
anyResolved.Should().ContainKey(name,
$"'{name}' is listed as intentionally degraded but no such change was exercised");
anyResolved.GetValueOrDefault(name).Should().BeFalse(
$"'{name}' is listed as intentionally degraded but now resolves a Subject; remove it from {nameof(IntentionallyDegraded)}");
}
}

// Entity type name without the CLR generic-arity suffix, so a generic entity (RemoteResource<TMetadata>)
// reads as "RemoteResource". The resolved entity types are all non-generic, so stripping is harmless there.
private static string EntityTypeName(Type entityType) => entityType.Name.Split('`')[0];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Globalization;

namespace LcmCrdt.Tests;

public class ActivityChangeInfoResolverExampleSnippetTests
{
private static RichMultiString Sentence(string text) => new() { ["en"] = new RichString(text) };

private const int Budget = ActivityChangeInfoResolver.TextSnippetBudget;

[Theory]
// Fits within the budget → returned whole, no ellipsis.
[InlineData("I run every morning", "I run every morning")] // 19 chars
[InlineData("12345678901234567890", "12345678901234567890")] // exactly 20
// Over budget → cut back to the last space in the kept window (so a word isn't split), then ellipsis.
[InlineData("The quick brown fox jumps over the lazy dog", "The quick brown fox…")] // 4 words
// A single long token with no space to break on → hard grapheme cut at the budget.
[InlineData("Supercalifragilisticexpialidocious is long", "Supercalifragilistic…")]
// A space in the first half of the window is ignored — backing off to it would collapse the snippet to "ab…".
[InlineData("ab Supercalifragilisticexpialidocious", "ab Supercalifragilis…")]
public void ExampleSnippet_TruncatesSpacedText(string input, string expected)
{
ActivityChangeInfoResolver.ExampleSnippet(Sentence(input)).Should().Be(expected);
}

[Fact]
public void ExampleSnippet_RespectsWritingSystemOrder()
{
// "fr" is configured first, so it wins even though "en" sorts earlier by code.
var sentence = new RichMultiString { ["en"] = new RichString("hello"), ["fr"] = new RichString("bonjour") };
var wsOrder = new Dictionary<WritingSystemId, int> { ["fr"] = 0, ["en"] = 1 };
ActivityChangeInfoResolver.ExampleSnippet(sentence, wsOrder).Should().Be("bonjour");
}

[Fact]
public void ExampleSnippet_FallsBackToWritingSystemCode_WhenNoOrderGiven()
{
// With no project writing-system order supplied, selection falls back to WS code; "en" sorts before "fr".
var sentence = new RichMultiString { ["fr"] = new RichString("bonjour"), ["en"] = new RichString("hello") };
ActivityChangeInfoResolver.ExampleSnippet(sentence).Should().Be("hello");
}

[Fact]
public void ExampleSnippet_FlattensSpansAndCollapsesWhitespace()
{
var sentence = new RichMultiString
{
["en"] = new RichString([
new RichSpan { Text = "I ", Bold = RichTextToggle.On },
new RichSpan { Text = "run\n\n daily" },
]),
};
ActivityChangeInfoResolver.ExampleSnippet(sentence).Should().Be("I run daily");
}

[Fact]
public void ExampleSnippet_SkipsEmptyWritingSystems()
{
var sentence = new RichMultiString { ["en"] = new RichString(" "), ["seh"] = new RichString("nyumba") };
ActivityChangeInfoResolver.ExampleSnippet(sentence).Should().Be("nyumba");
}

[Fact]
public void ExampleSnippet_NoDisplayableText_ReturnsNull()
{
ActivityChangeInfoResolver.ExampleSnippet(new RichMultiString()).Should().BeNull();
ActivityChangeInfoResolver.ExampleSnippet(new RichMultiString { ["en"] = new RichString("") }).Should().BeNull();
}

[Fact]
public void ExampleSnippet_NoSpaceScript_HardCutsAtGraphemeBoundary()
{
// CJK has no inter-word spaces; there's nothing to break on, so it's a clean cut at the budget.
var text = new string('好', 25);
ActivityChangeInfoResolver.ExampleSnippet(Sentence(text)).Should().Be(new string('好', 20) + "…");
}

[Fact]
public void ExampleSnippet_CombiningMarks_NotSplitMidGrapheme()
{
// "a" + combining acute accent = one grapheme, two chars. Truncation must count graphemes, not chars,
// and never cut between the base and its mark.
var text = string.Concat(Enumerable.Repeat("á", Budget + 5)); // 25 graphemes, 50 chars
var result = ActivityChangeInfoResolver.ExampleSnippet(Sentence(text));

result.Should().EndWith("…");
var kept = result[..^1];
new StringInfo(kept).LengthInTextElements.Should().Be(Budget);
kept.Length.Should().Be(Budget * 2); // 20 graphemes × 2 chars each, i.e. no pair was split
}

[Fact]
public void ExampleSnippet_SurrogatePairs_NotSplit()
{
// Emoji outside the BMP are surrogate pairs (two chars, one grapheme); a char-based cut would corrupt them.
var text = string.Concat(Enumerable.Repeat("😀", count: Budget + 5));
var result = ActivityChangeInfoResolver.ExampleSnippet(Sentence(text));

result.Should().Be(string.Concat(Enumerable.Repeat("😀", Budget)) + "…");
}
}
21 changes: 21 additions & 0 deletions backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,27 @@ public void ChangesIncludeAllExpectedChangeTypes()
}
}

// Every change type, dependency-ordered so it can be applied in one pass. Shared with
// ActivityChangeInfoResolverCoverageTests, which drives the whole catalogue through the activity feed.
internal static IReadOnlyList<IChange> AllChangesInDependencyOrder()
{
var remaining = GetAllChanges().ToList();
var ordered = new List<IChange>(remaining.Count);
while (remaining.Count > 0)
{
var ready = remaining
.Where(c => c.Dependencies is null || c.Dependencies.All(d => ordered.Contains(d)))
.ToList();
if (ready.Count == 0) throw new InvalidOperationException("Cyclic or unsatisfiable change dependencies");
foreach (var c in ready)
{
ordered.Add(c.Change);
remaining.Remove(c);
}
}
return ordered;
}

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

private static IEnumerable<ChangeWithDependencies> GetAllChanges()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public async Task CommitTimestamp_SameUtcInstant_ThroughEfCoreAndLinq2db()
// switches to EF this keeps the linq2db path (the one that needed the fix) under test.
var l2dbTimestamp = await ctx.Set<Commit>().Where(c => c.Id == commit.Id)
.ToLinqToDB().Select(c => c.HybridDateTime.DateTime).FirstAsyncLinqToDB();
var activityTimestamp = (await History.ProjectActivity(0, 1000).ToArrayAsync()).First(a => a.CommitId == commit.Id).Timestamp;
var activityTimestamp = (await History.ProjectActivity(0, 1000)).First(a => a.CommitId == commit.Id).Timestamp;
var historyTimestamp = (await History.GetHistory(entryId).ToArrayAsync()).First(h => h.CommitId == commit.Id).Timestamp;

foreach (var (name, value) in new[]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using LcmCrdt.Changes;
using LcmCrdt.Changes.Comments;
using SystemTextJsonPatch;

namespace LcmCrdt.Tests;

/// <summary>
/// Pins down the label policy described on <see cref="ActivityChangeInfoResolver"/> with one test per behavior:
/// identifying labels track the newest known state (so they drift as the data changes), comment snippets quote
/// the written text (so they never drift), plus the sense-numbering quirk the policy causes.
/// </summary>
public class HistoryServiceActivityLabelPolicyTests : HistoryServiceActivityTestsBase
{
[Fact]
public async Task ProjectActivity_ChangeInfo_IdentifyingLabels_TrackCurrentState_EvenAfterDelete()
{
var entryId = await CreateEntry("run");
var rename = new JsonPatchDocument<Entry>();
rename.Replace(e => e.LexemeForm["en"], "jog");
await DataModel.AddChange(ClientId, new JsonPatchChange<Entry>(entryId, rename), Meta());

var renamed = await Service.ProjectActivity(0, 100, new ActivityQuery());
// The change that created the entry as "run" now names it by its current headword.
renamed.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateEntryChange) && a.Changes[0].Info.Subject == "jog");

await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange<Entry>(entryId), Meta());

var deleted = await Service.ProjectActivity(0, 100, new ActivityQuery());
// Deletion has no effect: the label recovered from the last snapshot is the renamed one.
deleted.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateEntryChange) && a.Changes[0].Info.Subject == "jog");
}

[Fact]
public async Task ProjectActivity_ChangeInfo_CommentSnippets_QuoteWrittenText_NeverDrift()
{
var entryId = await CreateEntry("run");
var threadId = await AddCommentThread(entryId, SubjectType.Entry);
var commentId = await AddComment(threadId, "original words");
await DataModel.AddChange(ClientId, new EditUserCommentChange(commentId, "second words", DateTimeOffset.UtcNow), Meta());
await DataModel.AddChange(ClientId, new EditUserCommentChange(commentId, "third words", DateTimeOffset.UtcNow), Meta());

var activities = await Service.ProjectActivity(0, 100, new ActivityQuery());
activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateUserCommentChange)
&& a.Changes[0].Info.Target == "original words");
activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is EditUserCommentChange)
&& a.Changes[0].Info.Target == "second words");
activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is EditUserCommentChange)
&& a.Changes[0].Info.Target == "third words");
}

[Fact]
public async Task ProjectActivity_ChangeInfo_SenseNumbers_ReflectCurrentPositions()
{
var entryId = await CreateEntry("run");
await CreateSense(entryId, gloss: "first", order: 1.0);
await CreateSense(entryId, gloss: "early", order: 0.9);

var activities = await Service.ProjectActivity(0, 100, new ActivityQuery());
activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateSenseChange)
&& a.Changes[0].Info.Target == "first₂");
}
}
Loading
Loading