Skip to content
22 changes: 20 additions & 2 deletions backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,27 @@ public Task<IObjectWithId> GetObject(Guid commitId, Guid entityId)
}

[JSInvokable]
public async ValueTask<ProjectActivity[]> ProjectActivity(int skip, int take)
public async ValueTask<ProjectActivity[]> ProjectActivity(
int skip,
int take,
string[]? authorFilterKeys = null,
string[]? changeTypeKeys = null,
ActivitySort sort = ActivitySort.NewestFirst)
{
return await historyService.ProjectActivity(skip, take).ToArrayAsync();
return await historyService.ProjectActivity(skip, take,
new ActivityQuery(authorFilterKeys, changeTypeKeys, sort)).ToArrayAsync();
}

[JSInvokable]
public Task<ActivityAuthor[]> ListActivityAuthors()
{
return historyService.ListActivityAuthors();
}

[JSInvokable]
public Task<ActivityChangeType[]> ListActivityChangeTypes()
{
return historyService.ListActivityChangeTypes();
}

[JSInvokable]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(FwLiteConfig),
typeof(HistoryLineItem),
typeof(ProjectActivity),
typeof(ActivityAuthor),
typeof(ActivityChangeType),
typeof(ActivityQuery),
typeof(ChangeContext),
typeof(ChangeEntity<IChange>),
typeof(IChange),
Expand All @@ -184,6 +187,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(AvailableUpdate),
], exportBuilder => exportBuilder.WithPublicProperties());

builder.ExportAsEnum<ActivitySort>().UseString();
builder.ExportAsEnum<FwEventType>().UseString();
builder.ExportAsEnum<LogLevel>().UseString(false);
var eventJsAttrs = typeof(IFwEvent).GetCustomAttributes<JsonDerivedTypeAttribute>();
Expand Down
11 changes: 10 additions & 1 deletion backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app)
});
return Task.CompletedTask;
});
group.MapGet("/", (HistoryService historyService, int skip, int take) => historyService.ProjectActivity(skip, take));
group.MapGet("/", (
HistoryService historyService,
int skip = 0,
int take = 100,
string[]? authorFilterKeys = null,
string[]? changeTypeKeys = null,
ActivitySort sort = ActivitySort.NewestFirst) =>
historyService.ProjectActivity(skip, take, new ActivityQuery(authorFilterKeys, changeTypeKeys, sort)));
group.MapGet("/authors", (HistoryService historyService) => historyService.ListActivityAuthors());
group.MapGet("/change-types", (HistoryService historyService) => historyService.ListActivityChangeTypes());
return group;
}
}
166 changes: 166 additions & 0 deletions backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using LcmCrdt.Changes;
using LcmCrdt.Utils;
using Microsoft.EntityFrameworkCore;
using MiniLcm.Tests.AutoFakerHelpers;
using SIL.Harmony.Core;
using Soenneker.Utils.AutoBogus;

namespace LcmCrdt.Tests;

public class HistoryServiceActivityTests : IAsyncLifetime, IAsyncDisposable
{
private static readonly AutoFaker AutoFaker = new(AutoFakerDefault.MakeConfig(["en"]));
private MiniLcmApiFixture _fixture = null!;

private HistoryService Service => _fixture.GetService<HistoryService>();
private DataModel DataModel => _fixture.DataModel;
private Guid ClientId => _fixture.GetService<CurrentProjectService>().ProjectData.ClientId;

public async Task InitializeAsync()
{
_fixture = MiniLcmApiFixture.Create();
await _fixture.InitializeAsync();
}

public async Task DisposeAsync() => await _fixture.DisposeAsync();

async ValueTask IAsyncDisposable.DisposeAsync() => await DisposeAsync();

[Fact]
public async Task ListActivityAuthors_ReturnsDistinctAuthorsWithCounts()
{
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
await AddEntryCommit(new CommitMetadata { AuthorName = "Bob", AuthorId = "bob-id" });

var authors = await Service.ListActivityAuthors();

authors.Should().Contain(a => a.AuthorId == "alice-id" && a.AuthorName == "Alice" && a.CommitCount == 2);
authors.Should().Contain(a => a.AuthorId == "bob-id" && a.AuthorName == "Bob" && a.CommitCount == 1);
}

[Fact]
public async Task ListActivityChangeTypes_IncludesCreateEntry()
{
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });

var changeTypes = await Service.ListActivityChangeTypes();

changeTypes.Should().Contain(t => t.Key == "CreateEntryChange" && t.CommitCount >= 1);
changeTypes.Single(t => t.Key == "CreateEntryChange").Label.Should().Be("Create entry");
}

[Fact]
public async Task ProjectActivity_FiltersByAuthorId()
{
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
await AddEntryCommit(new CommitMetadata { AuthorName = "Bob", AuthorId = "bob-id" });

var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync();

activities.Should().OnlyContain(a => a.Metadata.AuthorId == "alice-id");
activities.Should().HaveCountGreaterThanOrEqualTo(1);
}

[Fact]
public async Task ProjectActivity_AuthorFilterKeys_ExcludesUnselectedAuthors()
{
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
await AddEntryCommit(new CommitMetadata { AuthorName = "FieldWorks" });

var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync();

activities.Should().NotContain(a => a.Metadata.AuthorName == "FieldWorks");
activities.Should().Contain(a => a.Metadata.AuthorName == "Alice");
}

[Fact]
public async Task ProjectActivity_SortsOldestFirst()
{
await AddEntryCommit(new CommitMetadata { AuthorName = "First", AuthorId = "first" }, "alpha");
await Task.Delay(5);
await AddEntryCommit(new CommitMetadata { AuthorName = "Second", AuthorId = "second" }, "beta");

var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.OldestFirst)).ToArrayAsync();
var firstIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "first");
var secondIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "second");
firstIndex.Should().BeGreaterThanOrEqualTo(0);
secondIndex.Should().BeGreaterThan(firstIndex);
}

[Fact]
public async Task ProjectActivity_SyncedSort_PlacesUnsyncedLast()
{
var syncedCommit = await AddEntryCommit(new CommitMetadata { AuthorName = "Synced", AuthorId = "synced" }, "synced-entry");
await SetSyncDate(syncedCommit.Id, new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
await AddEntryCommit(new CommitMetadata { AuthorName = "Unsynced", AuthorId = "unsynced" }, "unsynced-entry");

var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.SyncedNewestFirst)).ToArrayAsync();
var syncedIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "synced");
var unsyncedIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "unsynced");
syncedIndex.Should().BeGreaterThanOrEqualTo(0);
unsyncedIndex.Should().BeGreaterThanOrEqualTo(0);
syncedIndex.Should().BeLessThan(unsyncedIndex);
}

[Fact]
public async Task ProjectActivity_PaginationRespectsFilters()
{
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
await AddEntryCommit(new CommitMetadata { AuthorName = "Bob", AuthorId = "bob-id" });

var page = await Service.ProjectActivity(0, 1, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync();

page.Should().HaveCount(1);
page[0].Metadata.AuthorId.Should().Be("alice-id");
}

[Fact]
public async Task ProjectActivity_FiltersByChangeTypeKeys()
{
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });

var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: ["CreateEntryChange"])).ToArrayAsync();

activities.Should().OnlyContain(a => a.ChangeTypes.Contains("CreateEntryChange"));
activities.Should().HaveCountGreaterThanOrEqualTo(1);
}

[Fact]
public async Task ProjectActivity_ChangeTypeKeys_FiltersMultipleTypes()
{
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });

var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: ["CreateEntryChange", "MissingType"])).ToArrayAsync();

activities.Should().OnlyContain(a => a.ChangeTypes.Any(t => t == "CreateEntryChange"));
}

[Fact]
public async Task ProjectActivity_IncludesChangeTypes()
{
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });

var activity = await Service.ProjectActivity(0, 1).SingleAsync();

activity.ChangeTypes.Should().Contain("CreateEntryChange");
}

private async Task<Commit> AddEntryCommit(CommitMetadata metadata, string? headword = null)
{
var entry = headword is null
? await AutoFaker.EntryReadyForCreation(_fixture.Api)
: new Entry { Id = Guid.NewGuid(), LexemeForm = new MultiString { ["en"] = headword } };
return await DataModel.AddChange(ClientId, new CreateEntryChange(entry), metadata);
}

private async Task SetSyncDate(Guid commitId, DateTimeOffset syncDate)
{
var db = _fixture.DbContext;
var commit = await db.Set<Commit>().SingleAsync(c => c.Id == commitId);
commit.SetSyncDate(syncDate);
db.Entry(commit).Property(c => c.Metadata).IsModified = true;
await db.SaveChangesAsync();
}
}
Loading
Loading