438 lines
14 KiB
C#
438 lines
14 KiB
C#
// <copyright file="FacetDriftVexEmitterTests.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
|
// </copyright>
|
|
// Sprint: SPRINT_20260105_002_003_FACET (QTA-020)
|
|
|
|
using System.Collections.Immutable;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Facet.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="FacetDriftVexEmitter"/>.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FacetDriftVexEmitterTests
|
|
{
|
|
private readonly FakeTimeProvider _timeProvider;
|
|
private readonly FacetDriftVexEmitter _emitter;
|
|
private readonly FacetDriftVexEmitterOptions _options;
|
|
|
|
public FacetDriftVexEmitterTests()
|
|
{
|
|
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero));
|
|
_options = FacetDriftVexEmitterOptions.Default;
|
|
_emitter = new FacetDriftVexEmitter(_options, _timeProvider);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_WithNoRequiresVexFacets_ReturnsEmptyResult()
|
|
{
|
|
// Arrange
|
|
var report = CreateDriftReport(QuotaVerdict.Ok);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
Assert.Equal(0, result.DraftsEmitted);
|
|
Assert.Empty(result.Drafts);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_WithRequiresVexFacet_CreatesDraft()
|
|
{
|
|
// Arrange
|
|
var report = CreateDriftReport(QuotaVerdict.RequiresVex);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
Assert.Equal(1, result.DraftsEmitted);
|
|
Assert.Single(result.Drafts);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_DraftContainsCorrectImageDigest()
|
|
{
|
|
// Arrange
|
|
var report = CreateDriftReport(QuotaVerdict.RequiresVex, imageDigest: "sha256:abc123");
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
Assert.Equal("sha256:abc123", result.ImageDigest);
|
|
Assert.Equal("sha256:abc123", result.Drafts[0].ImageDigest);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_DraftContainsBaselineSealId()
|
|
{
|
|
// Arrange
|
|
var report = CreateDriftReport(QuotaVerdict.RequiresVex, baselineSealId: "seal-xyz");
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
Assert.Equal("seal-xyz", result.BaselineSealId);
|
|
Assert.Equal("seal-xyz", result.Drafts[0].BaselineSealId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_DraftHasDeterministicId()
|
|
{
|
|
// Arrange
|
|
var report = CreateDriftReport(QuotaVerdict.RequiresVex);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result1 = _emitter.EmitDrafts(context);
|
|
var result2 = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
Assert.Equal(result1.Drafts[0].DraftId, result2.Drafts[0].DraftId);
|
|
Assert.StartsWith("vexfd-", result1.Drafts[0].DraftId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_DraftIdsDifferForDifferentFacets()
|
|
{
|
|
// Arrange
|
|
var facetDrifts = new[]
|
|
{
|
|
CreateFacetDrift("facet-a", QuotaVerdict.RequiresVex),
|
|
CreateFacetDrift("facet-b", QuotaVerdict.RequiresVex)
|
|
};
|
|
var report = new FacetDriftReport
|
|
{
|
|
ImageDigest = "sha256:abc123",
|
|
BaselineSealId = "seal-123",
|
|
AnalyzedAt = _timeProvider.GetUtcNow(),
|
|
FacetDrifts = [.. facetDrifts],
|
|
OverallVerdict = QuotaVerdict.RequiresVex
|
|
};
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
Assert.Equal(2, result.DraftsEmitted);
|
|
Assert.NotEqual(result.Drafts[0].DraftId, result.Drafts[1].DraftId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_DraftContainsChurnInformation()
|
|
{
|
|
// Arrange
|
|
var report = CreateDriftReportWithChurn(25m);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
var summary = result.Drafts[0].DriftSummary;
|
|
Assert.Equal(25m, summary.ChurnPercent);
|
|
Assert.Equal(100, summary.BaselineFileCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_DraftHasCorrectExpirationTime()
|
|
{
|
|
// Arrange
|
|
var options = new FacetDriftVexEmitterOptions { DraftTtl = TimeSpan.FromDays(14) };
|
|
var emitter = new FacetDriftVexEmitter(options, _timeProvider);
|
|
var report = CreateDriftReport(QuotaVerdict.RequiresVex);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
var expectedExpiry = _timeProvider.GetUtcNow().AddDays(14);
|
|
Assert.Equal(expectedExpiry, result.Drafts[0].ExpiresAt);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_DraftHasCorrectReviewDeadline()
|
|
{
|
|
// Arrange
|
|
var options = new FacetDriftVexEmitterOptions { ReviewSlaDays = 5 };
|
|
var emitter = new FacetDriftVexEmitter(options, _timeProvider);
|
|
var report = CreateDriftReport(QuotaVerdict.RequiresVex);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
var expectedDeadline = _timeProvider.GetUtcNow().AddDays(5);
|
|
Assert.Equal(expectedDeadline, result.Drafts[0].ReviewDeadline);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_DraftRequiresReview()
|
|
{
|
|
// Arrange
|
|
var report = CreateDriftReport(QuotaVerdict.RequiresVex);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
Assert.True(result.Drafts[0].RequiresReview);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_DraftHasEvidenceLinks()
|
|
{
|
|
// Arrange
|
|
var report = CreateDriftReportWithChanges(added: 5, removed: 3, modified: 2);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
var links = result.Drafts[0].EvidenceLinks;
|
|
Assert.Contains(links, l => l.Type == "facet_drift_analysis");
|
|
Assert.Contains(links, l => l.Type == "baseline_seal");
|
|
Assert.Contains(links, l => l.Type == "added_files");
|
|
Assert.Contains(links, l => l.Type == "removed_files");
|
|
Assert.Contains(links, l => l.Type == "modified_files");
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_RationaleDescribesChurn()
|
|
{
|
|
// Arrange - 15 files added out of 100 baseline = 15.0% churn
|
|
var report = CreateDriftReportWithChurn(15m);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
var rationale = result.Drafts[0].Rationale;
|
|
Assert.Contains("15.0%", rationale);
|
|
Assert.Contains("quota", rationale, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_HighChurnTriggersWarningInNotes()
|
|
{
|
|
// Arrange
|
|
var options = new FacetDriftVexEmitterOptions { HighChurnThreshold = 20m };
|
|
var emitter = new FacetDriftVexEmitter(options, _timeProvider);
|
|
var report = CreateDriftReportWithChurn(35m);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
var notes = result.Drafts[0].ReviewerNotes;
|
|
Assert.NotNull(notes);
|
|
Assert.Contains("WARNING", notes);
|
|
Assert.Contains("High churn", notes);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_RemovedFilesTriggersNoteInReviewerNotes()
|
|
{
|
|
// Arrange
|
|
var report = CreateDriftReportWithChanges(added: 0, removed: 5, modified: 0);
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
var notes = result.Drafts[0].ReviewerNotes;
|
|
Assert.NotNull(notes);
|
|
Assert.Contains("removed", notes, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_RespectsMaxDraftsLimit()
|
|
{
|
|
// Arrange
|
|
var options = new FacetDriftVexEmitterOptions { MaxDraftsPerBatch = 2 };
|
|
var emitter = new FacetDriftVexEmitter(options, _timeProvider);
|
|
|
|
var facetDrifts = Enumerable.Range(0, 5)
|
|
.Select(i => CreateFacetDrift($"facet-{i}", QuotaVerdict.RequiresVex))
|
|
.ToImmutableArray();
|
|
|
|
var report = new FacetDriftReport
|
|
{
|
|
ImageDigest = "sha256:abc123",
|
|
BaselineSealId = "seal-123",
|
|
AnalyzedAt = _timeProvider.GetUtcNow(),
|
|
FacetDrifts = facetDrifts,
|
|
OverallVerdict = QuotaVerdict.RequiresVex
|
|
};
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
Assert.Equal(2, result.DraftsEmitted);
|
|
Assert.Equal(2, result.Drafts.Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_SkipsNonRequiresVexFacets()
|
|
{
|
|
// Arrange
|
|
var facetDrifts = new[]
|
|
{
|
|
CreateFacetDrift("facet-ok", QuotaVerdict.Ok),
|
|
CreateFacetDrift("facet-warn", QuotaVerdict.Warning),
|
|
CreateFacetDrift("facet-block", QuotaVerdict.Blocked),
|
|
CreateFacetDrift("facet-vex", QuotaVerdict.RequiresVex)
|
|
};
|
|
|
|
var report = new FacetDriftReport
|
|
{
|
|
ImageDigest = "sha256:abc123",
|
|
BaselineSealId = "seal-123",
|
|
AnalyzedAt = _timeProvider.GetUtcNow(),
|
|
FacetDrifts = [.. facetDrifts],
|
|
OverallVerdict = QuotaVerdict.RequiresVex
|
|
};
|
|
var context = new FacetDriftVexEmissionContext(report);
|
|
|
|
// Act
|
|
var result = _emitter.EmitDrafts(context);
|
|
|
|
// Assert
|
|
Assert.Equal(1, result.DraftsEmitted);
|
|
Assert.Equal("facet-vex", result.Drafts[0].FacetId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitDrafts_NullContext_ThrowsArgumentNullException()
|
|
{
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>(() => _emitter.EmitDrafts(null!));
|
|
}
|
|
|
|
#region Helper Methods
|
|
|
|
private FacetDriftReport CreateDriftReport(
|
|
QuotaVerdict verdict,
|
|
string imageDigest = "sha256:default",
|
|
string baselineSealId = "seal-default")
|
|
{
|
|
return new FacetDriftReport
|
|
{
|
|
ImageDigest = imageDigest,
|
|
BaselineSealId = baselineSealId,
|
|
AnalyzedAt = _timeProvider.GetUtcNow(),
|
|
FacetDrifts = [CreateFacetDrift("test-facet", verdict)],
|
|
OverallVerdict = verdict
|
|
};
|
|
}
|
|
|
|
private FacetDriftReport CreateDriftReportWithChurn(decimal churnPercent)
|
|
{
|
|
var addedCount = (int)(churnPercent * 100 / 100);
|
|
var addedFiles = Enumerable.Range(0, addedCount)
|
|
.Select(i => new FacetFileEntry($"/added{i}.txt", $"sha256:added{i}", 100, null))
|
|
.ToImmutableArray();
|
|
|
|
var facetDrift = new FacetDrift
|
|
{
|
|
FacetId = "test-facet",
|
|
Added = addedFiles,
|
|
Removed = [],
|
|
Modified = [],
|
|
DriftScore = churnPercent,
|
|
QuotaVerdict = QuotaVerdict.RequiresVex,
|
|
BaselineFileCount = 100
|
|
};
|
|
|
|
return new FacetDriftReport
|
|
{
|
|
ImageDigest = "sha256:churn-test",
|
|
BaselineSealId = "seal-churn",
|
|
AnalyzedAt = _timeProvider.GetUtcNow(),
|
|
FacetDrifts = [facetDrift],
|
|
OverallVerdict = QuotaVerdict.RequiresVex
|
|
};
|
|
}
|
|
|
|
private FacetDriftReport CreateDriftReportWithChanges(int added, int removed, int modified)
|
|
{
|
|
var addedFiles = Enumerable.Range(0, added)
|
|
.Select(i => new FacetFileEntry($"/added{i}.txt", $"sha256:added{i}", 100, null))
|
|
.ToImmutableArray();
|
|
|
|
var removedFiles = Enumerable.Range(0, removed)
|
|
.Select(i => new FacetFileEntry($"/removed{i}.txt", $"sha256:removed{i}", 100, null))
|
|
.ToImmutableArray();
|
|
|
|
var modifiedFiles = Enumerable.Range(0, modified)
|
|
.Select(i => new FacetFileModification(
|
|
$"/modified{i}.txt",
|
|
$"sha256:old{i}",
|
|
$"sha256:new{i}",
|
|
100,
|
|
110))
|
|
.ToImmutableArray();
|
|
|
|
var facetDrift = new FacetDrift
|
|
{
|
|
FacetId = "test-facet",
|
|
Added = addedFiles,
|
|
Removed = removedFiles,
|
|
Modified = modifiedFiles,
|
|
DriftScore = added + removed + modified,
|
|
QuotaVerdict = QuotaVerdict.RequiresVex,
|
|
BaselineFileCount = 100
|
|
};
|
|
|
|
return new FacetDriftReport
|
|
{
|
|
ImageDigest = "sha256:changes-test",
|
|
BaselineSealId = "seal-changes",
|
|
AnalyzedAt = _timeProvider.GetUtcNow(),
|
|
FacetDrifts = [facetDrift],
|
|
OverallVerdict = QuotaVerdict.RequiresVex
|
|
};
|
|
}
|
|
|
|
private FacetDrift CreateFacetDrift(string facetId, QuotaVerdict verdict)
|
|
{
|
|
var addedCount = verdict == QuotaVerdict.RequiresVex ? 50 : 0;
|
|
var addedFiles = Enumerable.Range(0, addedCount)
|
|
.Select(i => new FacetFileEntry($"/added{i}.txt", $"sha256:added{i}", 100, null))
|
|
.ToImmutableArray();
|
|
|
|
return new FacetDrift
|
|
{
|
|
FacetId = facetId,
|
|
Added = addedFiles,
|
|
Removed = [],
|
|
Modified = [],
|
|
DriftScore = addedCount,
|
|
QuotaVerdict = verdict,
|
|
BaselineFileCount = 100
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|