Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
<PackageVersion Include="SystemTextJsonPatch" Version="3.2.1" />
<PackageVersion Include="tusdotnet" Version="2.8.0" />
<PackageVersion Include="UUIDNext" Version="4.1.2" />
<PackageVersion Include="Verify.SystemJson" Version="1.4.1" />
<PackageVersion Include="Verify.Xunit" Version="28.2.1" />
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
Expand Down
1 change: 1 addition & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!sena-3-live.verified.sqlite
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task InitializeAsync()
await DownloadSena3();
}

public async Task<(CrdtMiniLcmApi CrdtApi, FwDataMiniLcmApi FwDataApi, IServiceProvider services, IDisposable cleanup)> SetupProjects()
public async Task<TestProject> SetupProjects()
{
var sena3MasterCopy = await DownloadSena3();

Expand All @@ -49,15 +49,14 @@ public async Task InitializeAsync()
.Value
.ProjectsFolder;
var fwDataProject = new FwDataProject(projectName, projectsFolder);
var fwDataProjectPath = Path.Combine(fwDataProject.ProjectsPath, fwDataProject.Name);
DirectoryHelper.Copy(sena3MasterCopy, fwDataProjectPath);
File.Move(Path.Combine(fwDataProjectPath, "sena-3.fwdata"), fwDataProject.FilePath);
DirectoryHelper.Copy(sena3MasterCopy, fwDataProject.ProjectFolder);
File.Move(Path.Combine(fwDataProject.ProjectFolder, "sena-3.fwdata"), fwDataProject.FilePath);
var fwDataMiniLcmApi = services.GetRequiredService<FwDataFactory>().GetFwDataMiniLcmApi(fwDataProject, false);

var crdtProject = await services.GetRequiredService<CrdtProjectsService>()
.CreateProject(new(projectName, projectName, FwProjectId: fwDataMiniLcmApi.ProjectId, SeedNewProjectData: false));
var crdtMiniLcmApi = (CrdtMiniLcmApi)await services.OpenCrdtProject(crdtProject);
return (crdtMiniLcmApi, fwDataMiniLcmApi, services, cleanup);
return new TestProject(crdtMiniLcmApi, fwDataMiniLcmApi, crdtProject, fwDataProject, services, cleanup);
}

public Task DisposeAsync()
Expand Down
13 changes: 13 additions & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/Fixtures/TestProject.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FwDataMiniLcmBridge;
using FwDataMiniLcmBridge.Api;
using LcmCrdt;

namespace FwLiteProjectSync.Tests.Fixtures;

public record TestProject(
CrdtMiniLcmApi CrdtApi, FwDataMiniLcmApi FwDataApi,
CrdtProject CrdtProject, FwDataProject FwDataProject,
IServiceProvider Services, IDisposable _cleanup) : IDisposable
{
public void Dispose() { _cleanup.Dispose(); }
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<ProjectReference Include="..\FwLiteProjectSync\FwLiteProjectSync.csproj" />
<ProjectReference Include="..\FwLiteWeb\FwLiteWeb.csproj" />
<ProjectReference Include="..\LcmCrdt.Tests\LcmCrdt.Tests.csproj" />
<ProjectReference Include="..\MiniLcm\MiniLcm.csproj" />
</ItemGroup>
<ItemGroup>
<!-- json files get imported by default in web projects, so exclude those here so they aren't imported again below -->
Expand All @@ -43,4 +44,4 @@
<Content Include="Mercurial\**" CopyToOutputDirectory="PreserveNewest" Watch="false" />
<Content Include="MercurialExtensions\**" CopyToOutputDirectory="PreserveNewest" Watch="false" />
</ItemGroup>
</Project>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using LcmCrdt.Tests.Data;
using FwDataMiniLcmBridge;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace FwLiteProjectSync.Tests;

public class ProjectSnapshotSerializationTests : BaseSerializationTest
{
public static IEnumerable<object[]> GetSena3SnapshotNames()
{
return GetSena3SnapshotPaths()
.Select(file => new object[] { Path.GetFileName(file) });
}

private static IEnumerable<string> GetSena3SnapshotPaths()
{
var snapshotsDir = RelativePath("Snapshots");
return Directory.GetFiles(snapshotsDir, "sena-3_snapshot.*.verified.txt");
}

[Theory]
[MemberData(nameof(GetSena3SnapshotNames))]
public async Task AssertSena3Snapshots(string sourceSnapshotName)
{
// arrange
var fwDataProject = new FwDataProject(
nameof(AssertSena3Snapshots),
Path.Combine(".", nameof(ProjectSnapshotSerializationTests)));

var snapshotPath = CrdtFwdataProjectSyncService.SnapshotPath(fwDataProject);
File.Copy(RelativePath($"Snapshots\\{sourceSnapshotName}"), snapshotPath, overwrite: true);

// act - read the current snapshot
var snapshot = await CrdtFwdataProjectSyncService.GetProjectSnapshot(fwDataProject)
?? throw new InvalidOperationException("Failed to load verified snapshot");

// assert - whatever about the snapshot (i.e. ensure deserialization does what we think)
snapshot.Entries.Single(e => e.Id == Guid.Parse("cd045907-e8fc-46a3-8f8d-f71bd956275f"))
.Senses.Single().ExampleSentences.Single().Translation.Should().NotBeEmpty();
}

[Fact]
public async Task LatestSena3SnapshotRoundTrips()
{
// arrange
var latestSnapshotPath = GetSena3SnapshotPaths()
.OrderDescending()
.First();

// act
var roundTrippedJson = await GetRoundTrippedIndentedSnapshot(latestSnapshotPath);

// assert
var verifyName = Path.GetFileName(latestSnapshotPath).Replace(".verified.txt", "");
await Verify(roundTrippedJson)
.UseStrictJson()
.UseDirectory("Snapshots")
.UseFileName(verifyName);
}

private static string RelativePath(string name, [CallerFilePath] string sourceFile = "")
{
return Path.Combine(
Path.GetDirectoryName(sourceFile) ??
throw new InvalidOperationException("Could not get directory of source file"),
name);
}

private async Task<string> GetRoundTrippedIndentedSnapshot(string sourceSnapshotPath, [CallerMemberName] string fwDataProjectName = "")
{
var fwDataProject = new FwDataProject(
fwDataProjectName,
Path.Combine(".", nameof(ProjectSnapshotSerializationTests)));

var snapshotPath = CrdtFwdataProjectSyncService.SnapshotPath(fwDataProject);
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath) ?? throw new InvalidOperationException("Could not get directory of snapshot path"));
File.Copy(sourceSnapshotPath, snapshotPath, overwrite: true);

var snapshot = await CrdtFwdataProjectSyncService.GetProjectSnapshot(fwDataProject)
?? throw new InvalidOperationException("Failed to load verified snapshot");
await CrdtFwdataProjectSyncService.SaveProjectSnapshot(fwDataProject, snapshot);

using var stream = File.OpenRead(snapshotPath);
var node = JsonNode.Parse(stream, null, new()
{
AllowTrailingCommas = true,
}) ?? throw new InvalidOperationException("Could not parse json node");
return node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}
}
118 changes: 112 additions & 6 deletions backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
using FluentAssertions.Equivalency;
using System.Runtime.CompilerServices;
using System.Text.Json;
using FluentAssertions.Execution;
using FwDataMiniLcmBridge.Api;
using FwLiteProjectSync.Tests.Fixtures;
using LcmCrdt;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MiniLcm;
using MiniLcm.Models;
using SystemTextJsonPatch;

namespace FwLiteProjectSync.Tests;

Expand All @@ -18,7 +19,7 @@ public class Sena3SyncTests : IClassFixture<Sena3Fixture>, IAsyncLifetime
private CrdtFwdataProjectSyncService _syncService = null!;
private CrdtMiniLcmApi _crdtApi = null!;
private FwDataMiniLcmApi _fwDataApi = null!;
private IDisposable? _cleanup;
private TestProject _project = null!;
private MiniLcmImport _miniLcmImport = null!;


Expand All @@ -29,15 +30,18 @@ public Sena3SyncTests(Sena3Fixture fixture)

public async Task InitializeAsync()
{
(_crdtApi, _fwDataApi, var services, _cleanup) = await _fixture.SetupProjects();
_project = await _fixture.SetupProjects();
_crdtApi = _project.CrdtApi;
_fwDataApi = _project.FwDataApi;
var services = _project.Services;
_syncService = services.GetRequiredService<CrdtFwdataProjectSyncService>();
_miniLcmImport = services.GetRequiredService<MiniLcmImport>();
_fwDataApi.EntryCount.Should().BeGreaterThan(100, "project should be loaded and have entries");
}

public Task DisposeAsync()
{
_cleanup?.Dispose();
_project.Dispose();
return Task.CompletedTask;
}

Expand Down Expand Up @@ -73,7 +77,7 @@ private async Task BypassImport(bool wsImported = false)
{
if (ws.MaybeId is null) ws.Id = Guid.NewGuid();
}
await _syncService.SaveProjectSnapshot(_fwDataApi.Project, snapshot);
await CrdtFwdataProjectSyncService.SaveProjectSnapshot(_fwDataApi.Project, snapshot);
}

//this lets us query entries when there is no writing system
Expand Down Expand Up @@ -183,4 +187,106 @@ public async Task SecondSena3SyncDoesNothing()
secondSync.CrdtChanges.Should().Be(0);
secondSync.FwdataChanges.Should().Be(0);
}

/// <summary>
/// This test maintains a "live" sena-3 crdt db and fw-headless snapshot
/// that walks through model changes and their sync result as they are made to the project.
/// By keeping both the db and snapshot in the repo we can observe and verify
/// the effects of any data changes over time (whether from serialization, new fields etc.)
/// </summary>
[Fact]
[Trait("Category", "Integration")]
public async Task LiveSena3Sync()
{
// arrange - put "live" crdt db and fw-headless snapshot in place
// we just ignore the crdt db that was created by the fixture and
// put in a copy of the "live" db in the same directory
var liveCrdtProject = new CrdtProject("sena-3-live",
Path.Join(Path.GetDirectoryName(_project.CrdtProject.DbPath), "sena-3-live.sqlite"));
File.Copy(
RelativePath("sena-3-live.verified.sqlite"),
liveCrdtProject.DbPath,
overwrite: true);
File.Copy(
RelativePath("sena-3-live_snapshot.verified.txt"),
CrdtFwdataProjectSyncService.SnapshotPath(_project.FwDataProject),
overwrite: true);
await using var liveScope = _project.Services.CreateAsyncScope();
var liveCrdtApi = await liveScope.ServiceProvider.OpenCrdtProject(liveCrdtProject);

// The default font used for the Analysis writing systems in our Sena 3 project differs when opened on
// Windows versus Linux. So, we standardize them to Charis SIL (which is the default on Linux).
// Otherwise, the snapshot verification isn't consistent.
await PatchAnalysisWsFontsWithCharisSIL(_fwDataApi);

// act
var result = await _syncService.Sync(liveCrdtApi, _fwDataApi);

// assert
var fwHeadlessSnapshot = await CrdtFwdataProjectSyncService.GetProjectSnapshot(_project.FwDataProject);
Exception? verifyException = null;
var throwAnyVerifyException = () => { if (verifyException is not null) throw verifyException; };
try
{
await Verify(JsonSerializer.Serialize(fwHeadlessSnapshot, new JsonSerializerOptions
{
WriteIndented = true,
}))
.UseStrictJson()
.UseFileName("sena-3-live_snapshot");
}
catch (Exception ex)
{
verifyException = ex;
}

if (result.CrdtChanges > 0)
{
// copy the updated "live" crdt db to a file for inspection,
// so the developer doesn't need to go find it and can decide if the changes are acceptable.
var dbContext = await liveScope.ServiceProvider.GetRequiredService<IDbContextFactory<LcmCrdtDbContext>>().CreateDbContextAsync();
BackupDatabase(dbContext, RelativePath("sena-3-live.received.sqlite"));
}

// updating the db and snapshot should always be done atomically
using (new AssertionScope())
{
result.CrdtChanges.Should().Be(0, "otherwise the live crdt db has changed and needs developer approval");
throwAnyVerifyException.Should().NotThrow("otherwise the fw-headless snapshot has changed and needs developer approval");
}

result.FwdataChanges.Should().Be(0);
}

private async Task PatchAnalysisWsFontsWithCharisSIL(FwDataMiniLcmApi fwDataApi)
{
var writingSystems = await fwDataApi.GetWritingSystems();
var analysisWs = writingSystems.Analysis;
analysisWs.Length.Should().Be(2);
await fwDataApi.UpdateWritingSystem(analysisWs[0].WsId, WritingSystemType.Analysis,
new UpdateObjectInput<WritingSystem>().Set(ws => ws.Font, "Charis SIL"));
await fwDataApi.UpdateWritingSystem(analysisWs[1].WsId, WritingSystemType.Analysis,
new UpdateObjectInput<WritingSystem>().Set(ws => ws.Font, "Charis SIL"));
}

private static string RelativePath(string name, [CallerFilePath] string sourceFile = "")
{
return Path.Combine(
Path.GetDirectoryName(sourceFile) ??
throw new InvalidOperationException("Could not get directory of source file"),
name);
}

private static void BackupDatabase(DbContext sourceContext, string destinationPath)
{
var source = (SqliteConnection)sourceContext.Database.GetDbConnection();
if (source.State != System.Data.ConnectionState.Open)
source.Open();

using var destination = new SqliteConnection($"Data Source={destinationPath}");
destination.Open();

source.BackupDatabase(destination);
source.Close();
}
}
Loading
Loading