Skip to content
Draft
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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<PackageVersion Include="System.IO.Hashing" Version="10.0.7" />
<PackageVersion Include="System.Linq.Async" Version="7.0.1" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="CsCheck" Version="4.8.0" />
<PackageVersion Include="FluentAssertions" Version="8.9.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.5" />
<PackageVersion Include="JetBrains.Profiler.SelfApi" Version="2.5.18" />
Expand Down
90 changes: 90 additions & 0 deletions src/SIL.Harmony.Tests/PropertyBased/ChangeSpecs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Globalization;
using SIL.Harmony.Changes;
using SIL.Harmony.Sample.Changes;
using SIL.Harmony.Sample.Models;

namespace SIL.Harmony.Tests.PropertyBased;

/// <summary>
/// An immutable, deterministic description of a single change, mintable into a real
/// <see cref="IChange"/>. Kept as data (not a live <see cref="IChange"/>) so a schedule can be
/// replayed and fed to many engines reproducibly. <see cref="ToChange"/> mints the runtime change;
/// <see cref="ToCode"/> renders the C# that reconstructs this spec, so a failing property can emit
/// a ready-to-run reproduction (see <see cref="ReproCode"/>).
/// </summary>
public abstract record ChangeSpec(Guid EntityId)
{
public abstract IChange ToChange();

/// <summary>Renders a C# expression that reconstructs this spec, e.g. <c>new SetWordTextSpec(Guid.Parse("..."), "t3")</c>.</summary>
public abstract string ToCode();
}

public sealed record SetWordTextSpec(Guid EntityId, string Text) : ChangeSpec(EntityId)
{
public override IChange ToChange() => new SetWordTextChange(EntityId, Text);
public override string ToCode() => $"new {nameof(SetWordTextSpec)}({ReproCode.Guid(EntityId)}, {ReproCode.Str(Text)})";
}

public sealed record NewWordSpec(Guid EntityId, string Text) : ChangeSpec(EntityId)
{
public override IChange ToChange() => new NewWordChange(EntityId, Text);
public override string ToCode() => $"new {nameof(NewWordSpec)}({ReproCode.Guid(EntityId)}, {ReproCode.Str(Text)})";
}

public sealed record SetWordNoteSpec(Guid EntityId, string Note) : ChangeSpec(EntityId)
{
public override IChange ToChange() => new SetWordNoteChange(EntityId, Note);
public override string ToCode() => $"new {nameof(SetWordNoteSpec)}({ReproCode.Guid(EntityId)}, {ReproCode.Str(Note)})";
}

public sealed record DeleteWordSpec(Guid EntityId) : ChangeSpec(EntityId)
{
public override IChange ToChange() => new DeleteChange<Word>(EntityId);
public override string ToCode() => $"new {nameof(DeleteWordSpec)}({ReproCode.Guid(EntityId)})";
}

public sealed record SetAntonymSpec(Guid EntityId, Guid AntonymId) : ChangeSpec(EntityId)
{
public override IChange ToChange() => new SetAntonymReferenceChange(EntityId, AntonymId);
public override string ToCode() => $"new {nameof(SetAntonymSpec)}({ReproCode.Guid(EntityId)}, {ReproCode.Guid(AntonymId)})";
}

public sealed record NewDefinitionSpec(Guid EntityId, Guid WordId, string Text, string PartOfSpeech, double Order)
: ChangeSpec(EntityId)
{
public override IChange ToChange() => new NewDefinitionChange(EntityId)
{
WordId = WordId,
Text = Text,
PartOfSpeech = PartOfSpeech,
Order = Order,
};

public override string ToCode() =>
$"new {nameof(NewDefinitionSpec)}({ReproCode.Guid(EntityId)}, {ReproCode.Guid(WordId)}, {ReproCode.Str(Text)}, {ReproCode.Str(PartOfSpeech)}, {ReproCode.Dbl(Order)})";
}

public sealed record SetDefinitionPartOfSpeechSpec(Guid EntityId, string PartOfSpeech) : ChangeSpec(EntityId)
{
public override IChange ToChange() => new SetDefinitionPartOfSpeechChange(EntityId, PartOfSpeech);
public override string ToCode() => $"new {nameof(SetDefinitionPartOfSpeechSpec)}({ReproCode.Guid(EntityId)}, {ReproCode.Str(PartOfSpeech)})";
}

public sealed record DeleteDefinitionSpec(Guid EntityId) : ChangeSpec(EntityId)
{
public override IChange ToChange() => new DeleteChange<Definition>(EntityId);
public override string ToCode() => $"new {nameof(DeleteDefinitionSpec)}({ReproCode.Guid(EntityId)})";
}

/// <summary>Type-specific projected-content signatures, used to compare entities across engines.</summary>
public static class EntitySignature
{
public static string Of(object dbObject) => dbObject switch
{
Word w => $"W:{w.Text}|{w.Note}|{w.AntonymId}|{w.ImageResourceId}",
Definition d =>
$"D:{d.Text}|{d.PartOfSpeech}|{d.Order.ToString(CultureInfo.InvariantCulture)}|{d.OneWordDefinition}|{d.WordId}",
_ => dbObject.ToString() ?? "",
};
}
51 changes: 51 additions & 0 deletions src/SIL.Harmony.Tests/PropertyBased/GeneratorMetaTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using CsCheck;
using FluentAssertions.Execution;

namespace SIL.Harmony.Tests.PropertyBased;

/// <summary>
/// Meta-tests over the generators themselves (no engine involved). They prove the generated
/// schedules actually contain author-time ties, stragglers, cascades, duplicates, and
/// genesis-depth rollbacks. If any of these stopped appearing, the property suite would still
/// go green while testing nothing — these tests fail loudly instead.
/// </summary>
public class GeneratorMetaTests
{
// Fixed, generous sample budget so every (even rare) axis appears with overwhelming
// probability. Independent of CsCheck_Iter so CI tuning of the real properties doesn't
// make this meta-check flaky.
private const int Iterations = 5000;

[Fact]
public void Tier1_Generator_Produces_AllRollbackPhenomena() => AssertProducesAllPhenomena(Generators.Tier1);

[Fact]
public void Tier2_Generator_Produces_AllRollbackPhenomena() => AssertProducesAllPhenomena(Generators.Tier2);

private static void AssertProducesAllPhenomena(Gen<Schedule> schedules)
{
var sawTie = false;
var sawStraggler = false;
var sawCascade = false;
var sawDuplicate = false;
var sawGenesisRebuild = false;

schedules.Sample(schedule =>
{
sawTie |= ScheduleAnalysis.HasAuthorTimeTie(schedule);
sawStraggler |= ScheduleAnalysis.StragglerCount(schedule.ArrivalsA) > 0;
sawCascade |= ScheduleAnalysis.MaxCascade(schedule.ArrivalsA) >= 2;
sawDuplicate |= ScheduleAnalysis.HasDuplicates(schedule.ArrivalsA)
|| ScheduleAnalysis.HasDuplicates(schedule.ArrivalsB);
sawGenesisRebuild |= ScheduleAnalysis.ForcesGenesisRebuild(schedule, schedule.ArrivalsA);
return true;
}, iter: Iterations);

using var _ = new AssertionScope();
sawTie.Should().BeTrue("the small author-time range must produce commits with equal author times (tiebreak path)");
sawStraggler.Should().BeTrue("arrival order independent of author time must produce stragglers (rollback path)");
sawCascade.Should().BeTrue("batches must sometimes contain multiple stragglers (cascading rollback)");
sawDuplicate.Should().BeTrue("the generator must re-send some commits (idempotency/dedup path)");
sawGenesisRebuild.Should().BeTrue("some straggler must arrive earlier than everything already folded (genesis-depth rollback)");
}
}
Loading
Loading