M1: audit log (Governance) + edit-distance metric

SharedKernel:
- IAuditLog/AuditEvent — append-only audit contract any module writes through.
- EditDistance (Levenshtein + normalized) — the north-star metric, available from day
  one; consumed at edit-and-approve in M5.

Governance module (references SharedKernel only):
- AuditEntry entity; internal GovernanceDbContext (schema "governance") +
  InitialGovernance migration; AuditLog implements IAuditLog.
- GET /api/governance/audit — owner-only (ViewAuditLog), returns recent entries.

Wiring (via the SharedKernel IAuditLog interface — no module references Governance):
- OrgBoard records team.created, task.created, task.moved, task.assigned.
- Identity records invitation.created, member.joined.

Verified: build green; ArchitectureTests 8/8 (Governance references only SharedKernel;
audit flows through the shared interface); IntegrationTests 20/20 — board flow now
asserts task.created/task.moved appear in the audit log, plus EditDistance unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-09 12:18:30 +03:30
parent e1911f58b1
commit fa9046a03e
16 changed files with 499 additions and 21 deletions
@@ -0,0 +1,25 @@
using TeamUp.Modules.Governance.Domain;
using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Auditing;
namespace TeamUp.Modules.Governance.Auditing;
/// <summary>
/// Writes audit events to the governance store. Uses its own DbContext/transaction (best-effort,
/// decoupled from the acting module's unit of work) — sufficient for M1.
/// </summary>
internal sealed class AuditLog(GovernanceDbContext db, TimeProvider clock) : IAuditLog
{
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{
db.AuditEntries.Add(new AuditEntry(
auditEvent.Action,
auditEvent.EntityType,
auditEvent.EntityId,
auditEvent.ActorMemberId,
auditEvent.Details,
clock.GetUtcNow()));
await db.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,34 @@
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.Governance.Domain;
/// <summary>An immutable audit record. Append-only — never updated or deleted.</summary>
internal sealed class AuditEntry : Entity
{
public string Action { get; private set; } = null!;
public string EntityType { get; private set; } = null!;
public Guid EntityId { get; private set; }
public Guid? ActorMemberId { get; private set; }
public string? Details { get; private set; }
public DateTimeOffset OccurredAtUtc { get; private set; }
private AuditEntry()
{
}
public AuditEntry(
string action,
string entityType,
Guid entityId,
Guid? actorMemberId,
string? details,
DateTimeOffset occurredAtUtc)
{
Action = action;
EntityType = entityType;
EntityId = entityId;
ActorMemberId = actorMemberId;
Details = details;
OccurredAtUtc = occurredAtUtc;
}
}
@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Governance.Endpoints;
internal sealed record AuditEntryResponse(
Guid Id,
string Action,
string EntityType,
Guid EntityId,
Guid? ActorMemberId,
string? Details,
DateTimeOffset OccurredAtUtc);
internal static class GovernanceEndpoints
{
public static void Map(IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/governance").WithTags("Governance");
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("governance")));
group.MapGet("/audit", GetAudit).RequireAuthorization();
}
private static async Task<IResult> GetAudit(
Guid organizationId, int? take, IPermissionService permissions, GovernanceDbContext db, CancellationToken ct)
{
// Owner-only. (M1 audit entries are not yet org-scoped — fine for single-org dogfood.)
if (!permissions.Has(Capability.ViewAuditLog, ScopeRef.Org(organizationId)))
{
return Results.Forbid();
}
var limit = Math.Clamp(take ?? 100, 1, 500);
var entries = await db.AuditEntries
.OrderByDescending(a => a.OccurredAtUtc)
.Take(limit)
.Select(a => new AuditEntryResponse(
a.Id, a.Action, a.EntityType, a.EntityId, a.ActorMemberId, a.Details, a.OccurredAtUtc))
.ToListAsync(ct);
return Results.Ok(entries);
}
}
@@ -1,27 +1,32 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using TeamUp.Modules.Governance.Auditing;
using TeamUp.Modules.Governance.Endpoints;
using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Governance;
/// <summary>Autonomy dial, the action gate, the review inbox, the audit log (M5).</summary>
/// <summary>Autonomy dial, the action gate, the review inbox, the audit log (M5). M1 ships the audit log.</summary>
public sealed class GovernanceModule : IModule
{
public string Name => "governance";
public void Register(IServiceCollection services, IConfiguration configuration)
{
// Skeleton: no services yet. M5 introduces the action gate, ReviewItem context,
// edit-distance capture, and the immutable audit log here.
var connectionString = configuration.GetConnectionString("Postgres")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
services.AddDbContext<GovernanceDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<GovernanceDbContext>());
services.AddScoped<IAuditLog, AuditLog>();
services.TryAddSingleton(TimeProvider.System);
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGroup($"/api/{Name}")
.WithTags("Governance")
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
}
public void MapEndpoints(IEndpointRouteBuilder endpoints) => GovernanceEndpoints.Map(endpoints);
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Governance.Domain;
using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Governance.Persistence;
internal sealed class GovernanceDbContext(DbContextOptions<GovernanceDbContext> options)
: DbContext(options), IModuleDbContext
{
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("governance");
modelBuilder.Entity<AuditEntry>(entry =>
{
entry.ToTable("audit_entries");
entry.HasKey(a => a.Id);
entry.Property(a => a.Action).HasMaxLength(100).IsRequired();
entry.Property(a => a.EntityType).HasMaxLength(100).IsRequired();
entry.Property(a => a.Details).HasMaxLength(2000);
entry.HasIndex(a => a.OccurredAtUtc);
entry.HasIndex(a => new { a.EntityType, a.EntityId });
});
}
}
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace TeamUp.Modules.Governance.Persistence;
/// <summary>Design-time factory so `dotnet ef` can build the internal context without a host.</summary>
internal sealed class GovernanceDbContextFactory : IDesignTimeDbContextFactory<GovernanceDbContext>
{
public GovernanceDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
var options = new DbContextOptionsBuilder<GovernanceDbContext>()
.UseNpgsql(connectionString)
.Options;
return new GovernanceDbContext(options);
}
}
@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeamUp.Modules.Governance.Persistence;
#nullable disable
namespace TeamUp.Modules.Governance.Persistence.Migrations
{
[DbContext(typeof(GovernanceDbContext))]
[Migration("20260609084417_InitialGovernance")]
partial class InitialGovernance
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("governance")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.AuditEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("Details")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OccurredAtUtc");
b.HasIndex("EntityType", "EntityId");
b.ToTable("audit_entries", "governance");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.Governance.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialGovernance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "governance");
migrationBuilder.CreateTable(
name: "audit_entries",
schema: "governance",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Action = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
EntityType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
EntityId = table.Column<Guid>(type: "uuid", nullable: false),
ActorMemberId = table.Column<Guid>(type: "uuid", nullable: true),
Details = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
OccurredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_audit_entries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_audit_entries_EntityType_EntityId",
schema: "governance",
table: "audit_entries",
columns: new[] { "EntityType", "EntityId" });
migrationBuilder.CreateIndex(
name: "IX_audit_entries_OccurredAtUtc",
schema: "governance",
table: "audit_entries",
column: "OccurredAtUtc");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "audit_entries",
schema: "governance");
}
}
}
@@ -0,0 +1,66 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeamUp.Modules.Governance.Persistence;
#nullable disable
namespace TeamUp.Modules.Governance.Persistence.Migrations
{
[DbContext(typeof(GovernanceDbContext))]
partial class GovernanceDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("governance")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.AuditEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("Details")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OccurredAtUtc");
b.HasIndex("EntityType", "EntityId");
b.ToTable("audit_entries", "governance");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,10 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
gains an (internal) DbContext and validators. It must never reference another module. -->
<!-- Autonomy, the action gate, the review inbox, and the audit log. In M1 it implements the
shared IAuditLog (append-only audit). References SharedKernel only. -->
<ItemGroup>
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
</Project>
@@ -8,6 +8,7 @@ using TeamUp.Modules.Identity.Auth;
using TeamUp.Modules.Identity.Domain;
using TeamUp.Modules.Identity.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Identity.Endpoints;
@@ -101,6 +102,7 @@ internal static class IdentityEndpoints
InviteRequest request,
ICurrentUser currentUser,
IPermissionService permissions,
IAuditLog audit,
IdentityDbContext db,
TimeProvider clock,
CancellationToken ct)
@@ -127,6 +129,8 @@ internal static class IdentityEndpoints
db.Invitations.Add(invitation);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(
new AuditEvent("invitation.created", "Invitation", invitation.Id, currentUser.MemberId, request.Email.Trim()), ct);
return Results.Ok(new InviteResponse(invitation.Id, token));
}
@@ -136,6 +140,7 @@ internal static class IdentityEndpoints
IdentityDbContext db,
IPasswordHasher<Member> hasher,
JwtTokenService tokens,
IAuditLog audit,
TimeProvider clock,
CancellationToken ct)
{
@@ -159,6 +164,7 @@ internal static class IdentityEndpoints
db.Members.Add(member);
db.Memberships.Add(membership);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("member.joined", "Member", member.Id, member.Id, member.Email), ct);
return Results.Ok(new AuthResponse(tokens.Issue(member, [membership]), member.Id));
}
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.OrgBoard.Endpoints;
@@ -61,8 +62,8 @@ internal static class OrgBoardEndpoints
}
private static async Task<IResult> CreateTeam(
CreateTeamRequest request, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
CreateTeamRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
{
@@ -82,6 +83,7 @@ internal static class OrgBoardEndpoints
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
db.Teams.Add(team);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("team.created", "Team", team.Id, user.MemberId, team.Name), ct);
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
}
@@ -104,7 +106,7 @@ internal static class OrgBoardEndpoints
private static async Task<IResult> CreateTask(
CreateTaskRequest request, ICurrentUser user, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
if (team is null)
@@ -125,6 +127,7 @@ internal static class OrgBoardEndpoints
var item = new WorkItem(team.Id, request.Title.Trim(), request.Description, request.Type, user.MemberId, clock.GetUtcNow());
db.WorkItems.Add(item);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.created", "WorkItem", item.Id, user.MemberId, item.Title), ct);
return Results.Ok(ToResponse(item));
}
@@ -153,8 +156,8 @@ internal static class OrgBoardEndpoints
}
private static async Task<IResult> MoveTask(
Guid id, MoveTaskRequest request, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
Guid id, MoveTaskRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
if (error is not null)
@@ -169,12 +172,13 @@ internal static class OrgBoardEndpoints
item!.MoveTo(request.Status, clock.GetUtcNow());
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.moved", "WorkItem", item.Id, user.MemberId, request.Status.ToString()), ct);
return Results.Ok(ToResponse(item));
}
private static async Task<IResult> AssignTask(
Guid id, AssignTaskRequest request, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
Guid id, AssignTaskRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
if (error is not null)
@@ -189,6 +193,7 @@ internal static class OrgBoardEndpoints
item!.AssignToMember(request.MemberId, clock.GetUtcNow());
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.assigned", "WorkItem", item.Id, user.MemberId, request.MemberId.ToString()), ct);
return Results.Ok(ToResponse(item));
}
@@ -0,0 +1,18 @@
namespace TeamUp.SharedKernel.Auditing;
/// <summary>An immutable record of a meaningful action. Written by any module via <see cref="IAuditLog"/>.</summary>
public sealed record AuditEvent(
string Action,
string EntityType,
Guid EntityId,
Guid? ActorMemberId,
string? Details = null);
/// <summary>
/// The append-only audit log. Defined in SharedKernel and implemented by Governance, so any module
/// can record an action without referencing Governance.
/// </summary>
public interface IAuditLog
{
Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,56 @@
namespace TeamUp.SharedKernel.Metrics;
/// <summary>
/// The product's north-star metric: how much a reviewer changes AI output before approving.
/// Levenshtein edit distance, available from day one (M1). It is consumed at edit-and-approve in
/// the review inbox (M5); kept here so the data path and computation exist before there is AI
/// output to measure.
/// </summary>
public static class EditDistance
{
/// <summary>Levenshtein distance — the minimum single-character edits to turn <paramref name="a"/> into <paramref name="b"/>.</summary>
public static int Levenshtein(string a, string b)
{
ArgumentNullException.ThrowIfNull(a);
ArgumentNullException.ThrowIfNull(b);
if (a.Length == 0)
{
return b.Length;
}
if (b.Length == 0)
{
return a.Length;
}
var previous = new int[b.Length + 1];
var current = new int[b.Length + 1];
for (var j = 0; j <= b.Length; j++)
{
previous[j] = j;
}
for (var i = 1; i <= a.Length; i++)
{
current[0] = i;
for (var j = 1; j <= b.Length; j++)
{
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);
}
(previous, current) = (current, previous);
}
return previous[b.Length];
}
/// <summary>Edit distance normalized to [0,1] by the longer string. 0 = unchanged (accepted as-is).</summary>
public static double Normalized(string original, string edited)
{
var max = Math.Max(original.Length, edited.Length);
return max == 0 ? 0d : (double)Levenshtein(original, edited) / max;
}
}
@@ -30,6 +30,10 @@ public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture<Pos
private sealed record BoardResponse(Guid TeamId, List<BoardColumn> Columns);
private sealed record AuditEntryResponse(
Guid Id, string Action, string EntityType, Guid EntityId,
Guid? ActorMemberId, string? Details, DateTimeOffset OccurredAtUtc);
[Fact]
public async Task Owner_builds_board_and_member_is_scoped()
{
@@ -76,6 +80,12 @@ public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture<Pos
var cartable = await ownerClient.GetFromJsonAsync<List<TaskResponse>>("/api/orgboard/cartable");
Assert.Contains(cartable!, i => i.Id == task.Id);
// The audit log (owner-only) recorded the task actions.
var audit = await ownerClient.GetFromJsonAsync<List<AuditEntryResponse>>(
$"/api/governance/audit?organizationId={owner.OrganizationId}");
Assert.Contains(audit!, e => e.Action == "task.created" && e.EntityId == task.Id);
Assert.Contains(audit!, e => e.Action == "task.moved" && e.EntityId == task.Id);
// Invite a Member at the org scope and accept.
var invite = await PostOk<InviteResponse>(ownerClient, "/api/identity/invitations", new
{
@@ -0,0 +1,26 @@
using TeamUp.SharedKernel.Metrics;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>Unit coverage for the north-star metric helper (no database).</summary>
public sealed class EditDistanceTests
{
[Theory]
[InlineData("", "", 0)]
[InlineData("abc", "abc", 0)]
[InlineData("", "abc", 3)]
[InlineData("abc", "", 3)]
[InlineData("kitten", "sitting", 3)]
[InlineData("spec v1", "spec v2", 1)]
public void Levenshtein_counts_edits(string a, string b, int expected) =>
Assert.Equal(expected, EditDistance.Levenshtein(a, b));
[Fact]
public void Normalized_is_zero_when_unchanged() =>
Assert.Equal(0d, EditDistance.Normalized("approved as-is", "approved as-is"));
[Fact]
public void Normalized_is_between_zero_and_one() =>
Assert.InRange(EditDistance.Normalized("the AI wrote this", "the human edited this"), 0d, 1d);
}