Refactor SurfaceCacheValidator to simplify oldest entry calculation

Add global using for Xunit in test project

Enhance ImportValidatorTests with async validation and quarantine checks

Implement FileSystemQuarantineServiceTests for quarantine functionality

Add integration tests for ImportValidator to check monotonicity

Create BundleVersionTests to validate version parsing and comparison logic

Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View File

@@ -264,5 +264,30 @@ public class ReachabilityScoringServiceTests
{
return Task.FromResult(Stored.Count);
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
var existing = Stored.FindIndex(x => x.Id == item.Id);
if (existing >= 0)
Stored[existing] = item;
else
Stored.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
Stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
Stored.Where(x => x.Band == band).Take(limit).ToList());
}
}
}

View File

@@ -0,0 +1,514 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MsOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
public class UnknownsDecayServiceTests
{
private readonly MockTimeProvider _timeProvider;
private readonly InMemoryUnknownsRepository _unknownsRepo;
private readonly InMemoryDeploymentRefsRepository _deploymentRefs;
private readonly InMemoryGraphMetricsRepository _graphMetrics;
private readonly UnknownsScoringOptions _scoringOptions;
private readonly UnknownsDecayOptions _decayOptions;
public UnknownsDecayServiceTests()
{
_timeProvider = new MockTimeProvider(new DateTimeOffset(2025, 12, 15, 12, 0, 0, TimeSpan.Zero));
_unknownsRepo = new InMemoryUnknownsRepository();
_deploymentRefs = new InMemoryDeploymentRefsRepository();
_graphMetrics = new InMemoryGraphMetricsRepository();
_scoringOptions = new UnknownsScoringOptions();
_decayOptions = new UnknownsDecayOptions();
}
private (UnknownsDecayService DecayService, UnknownsScoringService ScoringService) CreateServices()
{
var scoringService = new UnknownsScoringService(
_unknownsRepo,
_deploymentRefs,
_graphMetrics,
MsOptions.Options.Create(_scoringOptions),
_timeProvider,
NullLogger<UnknownsScoringService>.Instance);
var decayService = new UnknownsDecayService(
_unknownsRepo,
scoringService,
MsOptions.Options.Create(_scoringOptions),
MsOptions.Options.Create(_decayOptions),
_timeProvider,
NullLogger<UnknownsDecayService>.Instance);
return (decayService, scoringService);
}
#region ApplyDecayAsync Tests
[Fact]
public async Task ApplyDecayAsync_EmptySubject_ReturnsZeroCounts()
{
var (decayService, _) = CreateServices();
var result = await decayService.ApplyDecayAsync("empty|1.0.0", CancellationToken.None);
Assert.Equal("empty|1.0.0", result.SubjectKey);
Assert.Equal(0, result.ProcessedCount);
Assert.Equal(0, result.HotCount);
Assert.Equal(0, result.WarmCount);
Assert.Equal(0, result.ColdCount);
Assert.Equal(0, result.BandChanges);
}
[Fact]
public async Task ApplyDecayAsync_SingleUnknown_UpdatesAndPersists()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
const string subjectKey = "test|1.0.0";
var unknown = new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10),
Band = UnknownsBand.Cold
};
await _unknownsRepo.UpsertAsync(subjectKey, new[] { unknown }, CancellationToken.None);
var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None);
Assert.Equal(1, result.ProcessedCount);
Assert.Equal(subjectKey, result.SubjectKey);
// Verify the unknown was updated in the repository
var updated = await _unknownsRepo.GetBySubjectAsync(subjectKey, CancellationToken.None);
Assert.Single(updated);
Assert.True(updated[0].UpdatedAt >= now);
}
[Fact]
public async Task ApplyDecayAsync_BandChangesTracked()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
const string subjectKey = "test|1.0.0";
// Create unknown that will change from COLD to HOT due to high staleness and flags
var unknown = new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true,
ConflictingFeeds = true,
MissingVector = true
},
CreatedAt = now.AddDays(-20),
Band = UnknownsBand.Cold // Initially cold
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 100);
await _unknownsRepo.UpsertAsync(subjectKey, new[] { unknown }, CancellationToken.None);
var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None);
// Band should have changed from COLD to HOT
if (result.HotCount > 0)
{
Assert.Equal(1, result.BandChanges);
}
}
[Fact]
public async Task ApplyDecayAsync_MultipleUnknowns_ProcessesAll()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
const string subjectKey = "test|1.0.0";
var unknowns = new[]
{
new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = subjectKey,
LastAnalyzedAt = now,
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1),
Band = UnknownsBand.Cold
},
new UnknownSymbolDocument
{
Id = "unknown-2",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10),
Band = UnknownsBand.Warm
},
new UnknownSymbolDocument
{
Id = "unknown-3",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags { NoProvenanceAnchor = true, VersionRange = true },
CreatedAt = now.AddDays(-20),
Band = UnknownsBand.Hot
}
};
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None);
Assert.Equal(3, result.ProcessedCount);
Assert.Equal(result.HotCount + result.WarmCount + result.ColdCount, result.ProcessedCount);
}
#endregion
#region RunNightlyDecayBatchAsync Tests
[Fact]
public async Task RunNightlyDecayBatchAsync_ProcessesAllSubjects()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
// Create unknowns in multiple subjects
await _unknownsRepo.UpsertAsync("subject-1|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = "u1",
SubjectKey = "subject-1|1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10)
}
}, CancellationToken.None);
await _unknownsRepo.UpsertAsync("subject-2|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = "u2",
SubjectKey = "subject-2|1.0.0",
LastAnalyzedAt = now.AddDays(-3),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-5)
}
}, CancellationToken.None);
var result = await decayService.RunNightlyDecayBatchAsync(CancellationToken.None);
Assert.Equal(2, result.TotalSubjects);
Assert.Equal(2, result.TotalUnknowns);
Assert.True(result.Duration >= TimeSpan.Zero);
}
[Fact]
public async Task RunNightlyDecayBatchAsync_RespectsMaxSubjectsLimit()
{
var decayOptions = new UnknownsDecayOptions { MaxSubjectsPerBatch = 1 };
var scoringService = new UnknownsScoringService(
_unknownsRepo,
_deploymentRefs,
_graphMetrics,
MsOptions.Options.Create(_scoringOptions),
_timeProvider,
NullLogger<UnknownsScoringService>.Instance);
var decayService = new UnknownsDecayService(
_unknownsRepo,
scoringService,
MsOptions.Options.Create(_scoringOptions),
MsOptions.Options.Create(decayOptions),
_timeProvider,
NullLogger<UnknownsDecayService>.Instance);
var now = _timeProvider.GetUtcNow();
// Create unknowns in multiple subjects
await _unknownsRepo.UpsertAsync("subject-1|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = "u1",
SubjectKey = "subject-1|1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10)
}
}, CancellationToken.None);
await _unknownsRepo.UpsertAsync("subject-2|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = "u2",
SubjectKey = "subject-2|1.0.0",
LastAnalyzedAt = now.AddDays(-3),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-5)
}
}, CancellationToken.None);
var result = await decayService.RunNightlyDecayBatchAsync(CancellationToken.None);
// Should only process 1 subject due to limit
Assert.Equal(1, result.TotalSubjects);
Assert.Equal(1, result.TotalUnknowns);
}
[Fact]
public async Task RunNightlyDecayBatchAsync_CancellationRespected()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
// Create unknowns in multiple subjects
for (int i = 0; i < 10; i++)
{
await _unknownsRepo.UpsertAsync($"subject-{i}|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = $"u{i}",
SubjectKey = $"subject-{i}|1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10)
}
}, CancellationToken.None);
}
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
decayService.RunNightlyDecayBatchAsync(cts.Token));
}
#endregion
#region ApplyDecayToUnknownAsync Tests
[Fact]
public async Task ApplyDecayToUnknownAsync_UpdatesScoringFields()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10),
Score = 0,
Band = UnknownsBand.Cold
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50);
var result = await decayService.ApplyDecayToUnknownAsync(unknown, CancellationToken.None);
// Verify scoring fields were updated
Assert.True(result.Score > 0);
Assert.True(result.PopularityScore > 0);
Assert.True(result.StalenessScore > 0);
Assert.True(result.UncertaintyScore > 0);
Assert.NotNull(result.NextScheduledRescan);
Assert.NotNull(result.NormalizationTrace);
}
[Fact]
public async Task ApplyDecayToUnknownAsync_SetsNextRescanBasedOnBand()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
// Create unknown that will be scored as COLD
var coldUnknown = new UnknownSymbolDocument
{
Id = "cold-unknown",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now, // Fresh
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1)
};
var result = await decayService.ApplyDecayToUnknownAsync(coldUnknown, CancellationToken.None);
Assert.Equal(UnknownsBand.Cold, result.Band);
Assert.Equal(now.AddDays(_scoringOptions.ColdRescanDays), result.NextScheduledRescan);
}
#endregion
#region Decay Result Aggregation Tests
[Fact]
public async Task ApplyDecayAsync_ResultCountsAreAccurate()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
const string subjectKey = "test|1.0.0";
// Create unknowns that will end up in different bands
var unknowns = new List<UnknownSymbolDocument>();
// This will be COLD (fresh, no flags)
unknowns.Add(new UnknownSymbolDocument
{
Id = "cold-1",
SubjectKey = subjectKey,
LastAnalyzedAt = now,
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1)
});
// Add more with varying staleness and flags
for (int i = 0; i < 5; i++)
{
unknowns.Add(new UnknownSymbolDocument
{
Id = $"unknown-{i}",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-i * 2),
Flags = new UnknownFlags
{
NoProvenanceAnchor = i > 2,
VersionRange = i > 3
},
CreatedAt = now.AddDays(-i * 2 - 5)
});
}
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None);
Assert.Equal(6, result.ProcessedCount);
Assert.Equal(6, result.HotCount + result.WarmCount + result.ColdCount);
Assert.True(result.ColdCount >= 1); // At least the fresh one should be cold
}
#endregion
#region Test Infrastructure
private sealed class MockTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public MockTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
private sealed class InMemoryUnknownsRepository : IUnknownsRepository
{
private readonly List<UnknownSymbolDocument> _stored = new();
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
_stored.RemoveAll(x => x.SubjectKey == subjectKey);
_stored.AddRange(items);
return Task.CompletedTask;
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.SubjectKey == subjectKey).ToList());
}
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.Count(x => x.SubjectKey == subjectKey));
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
var existing = _stored.FindIndex(x => x.Id == item.Id);
if (existing >= 0)
_stored[existing] = item;
else
_stored.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
_stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.Band == band).Take(limit).ToList());
}
}
private sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository
{
private readonly Dictionary<string, int> _counts = new();
public void SetDeploymentCount(string purl, int count) => _counts[purl] = count;
public Task<int> CountDeploymentsAsync(string purl, CancellationToken cancellationToken)
{
return Task.FromResult(_counts.TryGetValue(purl, out var count) ? count : 0);
}
public Task<IReadOnlyList<string>> GetDeploymentIdsAsync(string purl, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
}
private sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository
{
private readonly Dictionary<string, GraphMetrics> _metrics = new();
public void SetMetrics(string symbolId, string callgraphId, GraphMetrics metrics)
{
_metrics[$"{symbolId}:{callgraphId}"] = metrics;
}
public Task<GraphMetrics?> GetMetricsAsync(string symbolId, string callgraphId, CancellationToken cancellationToken)
{
_metrics.TryGetValue($"{symbolId}:{callgraphId}", out var metrics);
return Task.FromResult(metrics);
}
}
#endregion
}

View File

@@ -78,5 +78,30 @@ public class UnknownsIngestionServiceTests
{
return Task.FromResult(Stored.Count);
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
var existing = Stored.FindIndex(x => x.Id == item.Id);
if (existing >= 0)
Stored[existing] = item;
else
Stored.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
Stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
Stored.Where(x => x.Band == band).Take(limit).ToList());
}
}
}

View File

@@ -0,0 +1,534 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MsOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
public class UnknownsScoringServiceTests
{
private readonly MockTimeProvider _timeProvider;
private readonly InMemoryUnknownsRepository _unknownsRepo;
private readonly InMemoryDeploymentRefsRepository _deploymentRefs;
private readonly InMemoryGraphMetricsRepository _graphMetrics;
private readonly UnknownsScoringOptions _defaultOptions;
public UnknownsScoringServiceTests()
{
_timeProvider = new MockTimeProvider(new DateTimeOffset(2025, 12, 15, 12, 0, 0, TimeSpan.Zero));
_unknownsRepo = new InMemoryUnknownsRepository();
_deploymentRefs = new InMemoryDeploymentRefsRepository();
_graphMetrics = new InMemoryGraphMetricsRepository();
_defaultOptions = new UnknownsScoringOptions();
}
private UnknownsScoringService CreateService(UnknownsScoringOptions? options = null)
{
return new UnknownsScoringService(
_unknownsRepo,
_deploymentRefs,
_graphMetrics,
MsOptions.Options.Create(options ?? _defaultOptions),
_timeProvider,
NullLogger<UnknownsScoringService>.Instance);
}
#region Staleness Exponential Decay Tests
[Fact]
public async Task ScoreUnknown_ExponentialDecay_FreshEvidence_LowStaleness()
{
// Fresh evidence (analyzed today) should have low staleness
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now, // Just analyzed
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Staleness should be close to 0 for fresh evidence
Assert.True(scored.StalenessScore < 0.05, $"Expected staleness < 0.05, got {scored.StalenessScore}");
Assert.Equal(0, scored.DaysSinceLastAnalysis);
}
[Fact]
public async Task ScoreUnknown_ExponentialDecay_StaleEvidence_HighStaleness()
{
// Old evidence (14 days) should have high staleness
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "unknown-2",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now.AddDays(-14), // 14 days old (tau default)
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-20)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// At t = tau, staleness should be significant (normalized based on exponential decay)
Assert.True(scored.StalenessScore > 0.5, $"Expected staleness > 0.5 at tau, got {scored.StalenessScore}");
Assert.Equal(14, scored.DaysSinceLastAnalysis);
}
[Fact]
public async Task ScoreUnknown_ExponentialDecay_NeverAnalyzed_MaxStaleness()
{
// Never analyzed should have maximum staleness
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "unknown-3",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = null, // Never analyzed
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-30)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Never analyzed = maximum staleness (1.0)
Assert.Equal(1.0, scored.StalenessScore);
Assert.Equal(_defaultOptions.StalenessMaxDays, scored.DaysSinceLastAnalysis);
}
[Theory]
[InlineData(0, 0.0)] // Fresh
[InlineData(7, 0.35)] // Half tau - moderate staleness
[InlineData(14, 0.70)] // At tau - significant staleness
[InlineData(28, 0.95)] // 2x tau - near max staleness
public async Task ScoreUnknown_ExponentialDecay_VerifyFormula(int daysOld, double expectedMinStaleness)
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = $"unknown-{daysOld}",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now.AddDays(-daysOld),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-daysOld - 5)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Staleness should be at least the expected minimum
Assert.True(scored.StalenessScore >= expectedMinStaleness * 0.8,
$"At {daysOld} days, expected staleness >= {expectedMinStaleness * 0.8}, got {scored.StalenessScore}");
Assert.Equal(daysOld, scored.DaysSinceLastAnalysis);
}
#endregion
#region Band Assignment Tests
[Fact]
public async Task ScoreUnknown_BandAssignment_HotThreshold()
{
// High score should assign HOT band
var service = CreateService();
var now = _timeProvider.GetUtcNow();
// Create unknown with high uncertainty flags to boost score
var unknown = new UnknownSymbolDocument
{
Id = "hot-unknown",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true, // +0.30
VersionRange = true, // +0.25
ConflictingFeeds = true, // +0.20
MissingVector = true // +0.15
},
CreatedAt = now.AddDays(-20)
};
// Set up deployments for popularity
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 100);
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// With high uncertainty (1.0) and high staleness, weighted score should hit HOT
Assert.Equal(UnknownsBand.Hot, scored.Band);
Assert.True(scored.Score >= _defaultOptions.HotThreshold,
$"Expected score >= {_defaultOptions.HotThreshold} for HOT, got {scored.Score}");
}
[Fact]
public async Task ScoreUnknown_BandAssignment_WarmThreshold()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
// Create unknown with moderate factors
var unknown = new UnknownSymbolDocument
{
Id = "warm-unknown",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true, // +0.30
VersionRange = true // +0.25
},
CreatedAt = now.AddDays(-10)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50);
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Should be in WARM band
Assert.Equal(UnknownsBand.Warm, scored.Band);
Assert.True(scored.Score >= _defaultOptions.WarmThreshold,
$"Expected score >= {_defaultOptions.WarmThreshold} for WARM, got {scored.Score}");
Assert.True(scored.Score < _defaultOptions.HotThreshold,
$"Expected score < {_defaultOptions.HotThreshold} for WARM, got {scored.Score}");
}
[Fact]
public async Task ScoreUnknown_BandAssignment_ColdThreshold()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
// Create unknown with low factors
var unknown = new UnknownSymbolDocument
{
Id = "cold-unknown",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now, // Fresh evidence
Flags = new UnknownFlags(), // No flags
CreatedAt = now.AddDays(-1)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 1);
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Should be in COLD band with minimal factors
Assert.Equal(UnknownsBand.Cold, scored.Band);
Assert.True(scored.Score < _defaultOptions.WarmThreshold,
$"Expected score < {_defaultOptions.WarmThreshold} for COLD, got {scored.Score}");
}
[Fact]
public async Task ScoreUnknown_BandAssignment_CustomThresholds()
{
var customOptions = new UnknownsScoringOptions
{
HotThreshold = 0.80,
WarmThreshold = 0.50
};
var service = CreateService(customOptions);
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "custom-unknown",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true
},
CreatedAt = now.AddDays(-10)
};
var scored = await service.ScoreUnknownAsync(unknown, customOptions, CancellationToken.None);
// With custom thresholds, verify correct band assignment
if (scored.Score >= 0.80)
Assert.Equal(UnknownsBand.Hot, scored.Band);
else if (scored.Score >= 0.50)
Assert.Equal(UnknownsBand.Warm, scored.Band);
else
Assert.Equal(UnknownsBand.Cold, scored.Band);
}
#endregion
#region Weight Formula Tests
[Fact]
public async Task ScoreUnknown_WeightedFormula_VerifyComponents()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "formula-test",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
SymbolId = "sym-1",
CallgraphId = "cg-1",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50);
_graphMetrics.SetMetrics("sym-1", "cg-1", new GraphMetrics(Degree: 10, Betweenness: 500.0));
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Verify normalization trace captures all weights
Assert.NotNull(scored.NormalizationTrace);
Assert.Equal(_defaultOptions.WeightPopularity, scored.NormalizationTrace!.Weights["wP"]);
Assert.Equal(_defaultOptions.WeightExploitPotential, scored.NormalizationTrace.Weights["wE"]);
Assert.Equal(_defaultOptions.WeightUncertainty, scored.NormalizationTrace.Weights["wU"]);
Assert.Equal(_defaultOptions.WeightCentrality, scored.NormalizationTrace.Weights["wC"]);
Assert.Equal(_defaultOptions.WeightStaleness, scored.NormalizationTrace.Weights["wS"]);
// Verify individual scores are in valid range
Assert.InRange(scored.PopularityScore, 0.0, 1.0);
Assert.InRange(scored.ExploitPotentialScore, 0.0, 1.0);
Assert.InRange(scored.UncertaintyScore, 0.0, 1.0);
Assert.InRange(scored.CentralityScore, 0.0, 1.0);
Assert.InRange(scored.StalenessScore, 0.0, 1.0);
// Verify final score is clamped
Assert.InRange(scored.Score, 0.0, 1.0);
}
[Fact]
public async Task ScoreUnknown_WeightedFormula_WeightsSumToOne()
{
// Verify default weights sum to 1.0
var sum = _defaultOptions.WeightPopularity +
_defaultOptions.WeightExploitPotential +
_defaultOptions.WeightUncertainty +
_defaultOptions.WeightCentrality +
_defaultOptions.WeightStaleness;
Assert.Equal(1.0, sum, 5);
}
#endregion
#region Rescan Scheduling Tests
[Fact]
public async Task ScoreUnknown_RescanScheduling_HotBand()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "hot-rescan",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true,
ConflictingFeeds = true,
MissingVector = true
},
CreatedAt = now.AddDays(-20)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 100);
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
if (scored.Band == UnknownsBand.Hot)
{
var expectedRescan = now.AddMinutes(_defaultOptions.HotRescanMinutes);
Assert.Equal(expectedRescan, scored.NextScheduledRescan);
}
}
[Fact]
public async Task ScoreUnknown_RescanScheduling_ColdBand()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "cold-rescan",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now,
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
Assert.Equal(UnknownsBand.Cold, scored.Band);
var expectedRescan = now.AddDays(_defaultOptions.ColdRescanDays);
Assert.Equal(expectedRescan, scored.NextScheduledRescan);
}
#endregion
#region Determinism Tests
[Fact]
public async Task ScoreUnknown_Determinism_SameInputsSameOutput()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown1 = new UnknownSymbolDocument
{
Id = "determinism-1",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10)
};
var unknown2 = new UnknownSymbolDocument
{
Id = "determinism-2",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50);
var scored1 = await service.ScoreUnknownAsync(unknown1, _defaultOptions, CancellationToken.None);
var scored2 = await service.ScoreUnknownAsync(unknown2, _defaultOptions, CancellationToken.None);
// Same inputs must produce identical scores
Assert.Equal(scored1.Score, scored2.Score);
Assert.Equal(scored1.Band, scored2.Band);
Assert.Equal(scored1.PopularityScore, scored2.PopularityScore);
Assert.Equal(scored1.StalenessScore, scored2.StalenessScore);
Assert.Equal(scored1.UncertaintyScore, scored2.UncertaintyScore);
}
#endregion
#region Test Infrastructure
private sealed class MockTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public MockTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
private sealed class InMemoryUnknownsRepository : IUnknownsRepository
{
private readonly List<UnknownSymbolDocument> _stored = new();
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
_stored.RemoveAll(x => x.SubjectKey == subjectKey);
_stored.AddRange(items);
return Task.CompletedTask;
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.SubjectKey == subjectKey).ToList());
}
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.Count(x => x.SubjectKey == subjectKey));
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
var existing = _stored.FindIndex(x => x.Id == item.Id);
if (existing >= 0)
_stored[existing] = item;
else
_stored.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
_stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.Band == band).Take(limit).ToList());
}
}
private sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository
{
private readonly Dictionary<string, int> _counts = new();
public void SetDeploymentCount(string purl, int count) => _counts[purl] = count;
public Task<int> CountDeploymentsAsync(string purl, CancellationToken cancellationToken)
{
return Task.FromResult(_counts.TryGetValue(purl, out var count) ? count : 0);
}
public Task<IReadOnlyList<string>> GetDeploymentIdsAsync(string purl, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
}
private sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository
{
private readonly Dictionary<string, GraphMetrics> _metrics = new();
public void SetMetrics(string symbolId, string callgraphId, GraphMetrics metrics)
{
_metrics[$"{symbolId}:{callgraphId}"] = metrics;
}
public Task<GraphMetrics?> GetMetricsAsync(string symbolId, string callgraphId, CancellationToken cancellationToken)
{
_metrics.TryGetValue($"{symbolId}:{callgraphId}", out var metrics);
return Task.FromResult(metrics);
}
}
#endregion
}