-
-
Notifications
You must be signed in to change notification settings - Fork 6
Activity redesign 1/8: resolve change subjects server-side and generate the change-type list #2435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
cba8373
Resolve activity change subjects server-side and generate the change-…
claude a1deb5a
Merge remote-tracking branch 'origin/develop' into claude/pr-2435-bef…
myieye 210a622
Improve type coverage, test coverage and type safety.
myieye 4aa8768
Tidy up and optimize
myieye 4eeebc7
Merge remote-tracking branch 'origin/develop' into claude/pr-2435-bef…
myieye e1fd214
Standardize terminology
myieye 57aa903
Solidify historical data policy
myieye 97cf4fa
Refactor object shapes to not enrich empty properties
myieye 06f86d3
Finalize comments and test coverage
myieye 647b08a
Reorder and simplify
myieye 8e1397f
Restore the audio writing-system filter dropped in the reorder
myieye f888250
Pass the now-required changeInfo in HistoryView
myieye 327c4c1
Simplify and standardize audio-ws filtering
myieye File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
33
backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")); | ||
|
myieye marked this conversation as resolved.
|
||
|
|
||
| // Suppress the marker class; only the raw union/array above is emitted. | ||
| return null!; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
61 changes: 61 additions & 0 deletions
61
backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]; | ||
| } |
101 changes: 101 additions & 0 deletions
101
backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) + "…"); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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₂"); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.