From 1cd15f06625b75914618222d201d95b646cb97d8 Mon Sep 17 00:00:00 2001 From: Artur Bobb Date: Wed, 15 Jul 2026 00:18:30 +0000 Subject: [PATCH 1/4] test: expand unit coverage and stabilize CI pipeline Co-authored-by: Artur B. --- .github/workflows/ci.yml | 37 ++++++++++-- .../HashChainTests.cs | 9 +++ .../MerkleTreeTests.cs | 8 +++ .../StreamingHashManifestTests.cs | 20 +++++++ .../MirrorSelectorTests.cs | 14 +++++ .../TransferPlanTests.cs | 17 ++++++ .../AdaptiveSamplerTests.cs | 7 +++ .../BurstCoalescerTests.cs | 14 +++++ .../EventFingerprinterTests.cs | 14 +++++ .../AdaptiveConcurrencyControllerTests.cs | 59 ++++++++++++++++++- .../MerkleFileTreeTests.cs | 10 ++++ .../TransactionalFileSetWriterTests.cs | 8 +++ 12 files changed, 210 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04341b8..6018d66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,11 +45,38 @@ jobs: run: dotnet build -c Release --no-restore - name: Test with coverage - run: > - dotnet test -c Release --no-build - --collect:"XPlat Code Coverage" - --results-directory ./artifacts/test-results - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura + run: | + set +e + FAILURES=0 + RESULTS_DIR=./artifacts/test-results + LOG="$RESULTS_DIR/test-output.log" + mkdir -p "$RESULTS_DIR" + : > "$LOG" + + for proj in tests/*/*.csproj; do + echo "=== Testing $proj ===" + dotnet test -c Release --no-build \ + --collect:"XPlat Code Coverage" \ + --results-directory "$RESULTS_DIR" \ + --logger "console;verbosity=normal" \ + "$proj" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \ + 2>&1 | tee -a "$LOG" + EXIT=$? + if [ "$EXIT" -ne 0 ]; then + FAILURES=$((FAILURES + 1)) + echo "::error::Tests failed for $proj (exit $EXIT)" + fi + done + + if grep -E 'Skipped:\s*[1-9][0-9]*' "$LOG"; then + echo "::error::One or more test runs reported skipped tests." + FAILURES=$((FAILURES + 1)) + fi + + if [ "$FAILURES" -ne 0 ]; then + exit 1 + fi - name: Pack run: | diff --git a/tests/FunctionFoundry.Integrity.Tests/HashChainTests.cs b/tests/FunctionFoundry.Integrity.Tests/HashChainTests.cs index 3e6a424..aad80f0 100644 --- a/tests/FunctionFoundry.Integrity.Tests/HashChainTests.cs +++ b/tests/FunctionFoundry.Integrity.Tests/HashChainTests.cs @@ -73,4 +73,13 @@ public void Serialize_record_body_is_canonical_json() Assert.Equal(canonical, body); Assert.DoesNotContain("recordHash", Encoding.UTF8.GetString(body), StringComparison.Ordinal); } + + [Fact] + public void Verify_empty_chain_is_invalid() + { + HashChainVerificationResult result = HashChain.Verify([]); + Assert.False(result.IsValid); + Assert.Equal(-1, result.FirstInvalidRecord?.Sequence); + Assert.Contains("at least one", result.FirstInvalidRecord?.Reason ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/FunctionFoundry.Integrity.Tests/MerkleTreeTests.cs b/tests/FunctionFoundry.Integrity.Tests/MerkleTreeTests.cs index 9f92b35..7aa9974 100644 --- a/tests/FunctionFoundry.Integrity.Tests/MerkleTreeTests.cs +++ b/tests/FunctionFoundry.Integrity.Tests/MerkleTreeTests.cs @@ -66,4 +66,12 @@ public void Sha384_option_is_supported() MerkleTree tree = MerkleTree.Build(["x"u8.ToArray()], new MerkleTreeOptions(ManifestHashAlgorithm.Sha384)); Assert.Equal(48, tree.Root.Length); } + + [Fact] + public void CreateProof_throws_for_out_of_range_index() + { + MerkleTree tree = MerkleTree.Build(["leaf"u8.ToArray()]); + Assert.Throws(() => tree.CreateProof(1)); + Assert.Throws(() => tree.CreateProof(-1)); + } } diff --git a/tests/FunctionFoundry.Integrity.Tests/StreamingHashManifestTests.cs b/tests/FunctionFoundry.Integrity.Tests/StreamingHashManifestTests.cs index dd4ae30..ef92c20 100644 --- a/tests/FunctionFoundry.Integrity.Tests/StreamingHashManifestTests.cs +++ b/tests/FunctionFoundry.Integrity.Tests/StreamingHashManifestTests.cs @@ -104,4 +104,24 @@ public void HashStream_matches_incremental_sha256() Assert.Equal(expected, hashHex); Assert.Equal(data.Length, size); } + + [Fact] + public void Verify_reports_size_mismatch_when_size_metadata_present() + { + var builder = StreamingHashManifest.CreateBuilder(); + builder.AddEntry("sized.txt", new MemoryStream("abc"u8.ToArray()), includeSize: true); + StreamingHashManifestDocument manifest = StreamingHashManifest.Parse(builder.Build()); + + var entries = new Dictionary + { + ["sized.txt"] = new MemoryStream("abcd"u8.ToArray()), + }; + + ManifestVerificationResult result = StreamingHashManifest.Verify(manifest, entries); + Assert.False(result.IsValid); + Assert.Single(result.SizeMismatches); + Assert.Equal("sized.txt", result.SizeMismatches[0].RelativePath); + Assert.Equal(3, result.SizeMismatches[0].ExpectedSizeBytes); + Assert.Equal(4, result.SizeMismatches[0].ActualSizeBytes); + } } diff --git a/tests/FunctionFoundry.Networking.Tests/MirrorSelectorTests.cs b/tests/FunctionFoundry.Networking.Tests/MirrorSelectorTests.cs index 83fc8e0..4cd890a 100644 --- a/tests/FunctionFoundry.Networking.Tests/MirrorSelectorTests.cs +++ b/tests/FunctionFoundry.Networking.Tests/MirrorSelectorTests.cs @@ -36,4 +36,18 @@ public void ExportEvidence_contains_component_scores() Assert.InRange(evidence.Score, 0, 1); Assert.True(evidence.LatencyScore > 0); } + + [Fact] + public void Select_throws_when_no_candidates() + { + var selector = new MirrorSelector(); + Assert.Throws(() => selector.Select([])); + } + + [Fact] + public void Options_validate_rejects_weights_that_do_not_sum_to_one() + { + var options = new MirrorSelectorOptions { LatencyWeight = 0.5, ThroughputWeight = 0.5, AvailabilityWeight = 0.5, IntegrityWeight = 0.5 }; + Assert.Throws(() => options.Validate()); + } } diff --git a/tests/FunctionFoundry.Networking.Tests/TransferPlanTests.cs b/tests/FunctionFoundry.Networking.Tests/TransferPlanTests.cs index 16ee284..c921be6 100644 --- a/tests/FunctionFoundry.Networking.Tests/TransferPlanTests.cs +++ b/tests/FunctionFoundry.Networking.Tests/TransferPlanTests.cs @@ -31,4 +31,21 @@ public void Repair_returns_only_corrupt_chunks() Assert.Single(repair); Assert.Equal(1, repair[0].Index); } + + [Fact] + public void Create_rejects_invalid_arguments() + { + Assert.Throws(() => TransferPlan.Create(-1, 16)); + Assert.Throws(() => TransferPlan.Create(100, 0)); + } + + [Fact] + public void Create_zero_size_has_empty_chunks_with_full_coverage() + { + TransferPlan plan = TransferPlan.Create(0, 16); + Assert.Empty(plan.Chunks); + TransferPlanValidationResult validation = plan.Validate(); + Assert.True(validation.HasFullCoverage); + Assert.False(validation.HasOverlaps); + } } diff --git a/tests/FunctionFoundry.Observability.Tests/AdaptiveSamplerTests.cs b/tests/FunctionFoundry.Observability.Tests/AdaptiveSamplerTests.cs index c5b5358..d1b97c6 100644 --- a/tests/FunctionFoundry.Observability.Tests/AdaptiveSamplerTests.cs +++ b/tests/FunctionFoundry.Observability.Tests/AdaptiveSamplerTests.cs @@ -79,4 +79,11 @@ public void Decide_supports_concurrent_calls() Assert.Equal(128, stats.TotalDecisions); Assert.Equal(stats.SampledCount + stats.DroppedCount, stats.TotalDecisions); } + + [Fact] + public void Options_validate_rejects_invalid_sample_rates() + { + var options = new AdaptiveSamplerOptions { BaseSampleRate = 0.2, MinSampleRate = 0.5 }; + Assert.Throws(() => options.Validate()); + } } diff --git a/tests/FunctionFoundry.Observability.Tests/BurstCoalescerTests.cs b/tests/FunctionFoundry.Observability.Tests/BurstCoalescerTests.cs index aeccea7..a7ff0e3 100644 --- a/tests/FunctionFoundry.Observability.Tests/BurstCoalescerTests.cs +++ b/tests/FunctionFoundry.Observability.Tests/BurstCoalescerTests.cs @@ -72,6 +72,20 @@ public void Record_supports_concurrent_producers() Assert.Equal(100, summary.TotalCount); } + [Fact] + public void Options_validate_rejects_non_positive_window() + { + var options = new BurstCoalescerOptions { Window = TimeSpan.Zero }; + Assert.Throws(() => options.Validate()); + } + + [Fact] + public void Record_throws_for_blank_fingerprint() + { + var coalescer = new BurstCoalescer(); + Assert.Throws(() => coalescer.Record(" ", "payload")); + } + private sealed class MutableClock(DateTimeOffset start) { private DateTimeOffset _now = start; diff --git a/tests/FunctionFoundry.Observability.Tests/EventFingerprinterTests.cs b/tests/FunctionFoundry.Observability.Tests/EventFingerprinterTests.cs index 8169eae..3e6675a 100644 --- a/tests/FunctionFoundry.Observability.Tests/EventFingerprinterTests.cs +++ b/tests/FunctionFoundry.Observability.Tests/EventFingerprinterTests.cs @@ -75,4 +75,18 @@ public void Fingerprint_exposes_explainable_components() Assert.True(fingerprint.Components.Count >= 3); Assert.All(fingerprint.Components, c => Assert.False(string.IsNullOrWhiteSpace(c.NormalizedValue))); } + + [Fact] + public void Fingerprint_throws_for_null_input() + { + var fingerprinter = new EventFingerprinter(); + Assert.Throws(() => fingerprinter.Fingerprint(null!)); + } + + [Fact] + public void Options_validate_rejects_negative_inner_exception_depth() + { + var options = new EventFingerprinterOptions { MaxInnerExceptionDepth = -1 }; + Assert.Throws(() => options.Validate()); + } } diff --git a/tests/FunctionFoundry.Resilience.Tests/AdaptiveConcurrencyControllerTests.cs b/tests/FunctionFoundry.Resilience.Tests/AdaptiveConcurrencyControllerTests.cs index ed620e7..b7839f0 100644 --- a/tests/FunctionFoundry.Resilience.Tests/AdaptiveConcurrencyControllerTests.cs +++ b/tests/FunctionFoundry.Resilience.Tests/AdaptiveConcurrencyControllerTests.cs @@ -22,8 +22,9 @@ public async Task Acquire_and_release_respects_max_concurrency() Assert.True(first.Acquired); Assert.True(second.Acquired); - Task blocked = controller.TryAcquireAsync(TimeSpan.FromMilliseconds(200)).AsTask(); - await Task.Delay(50); + Task blocked = controller.TryAcquireAsync(TimeSpan.FromSeconds(2)).AsTask(); + await WaitUntilAsync(() => controller.GetSnapshot().QueuedWaiters >= 1, TimeSpan.FromSeconds(2)); + AdaptiveConcurrencySnapshot snapshot = controller.GetSnapshot(); Assert.Equal(2, snapshot.ActivePermits); Assert.Equal(1, snapshot.QueuedWaiters); @@ -31,6 +32,7 @@ public async Task Acquire_and_release_respects_max_concurrency() controller.Release(first.Permit, new OperationFeedback(true, TimeSpan.FromMilliseconds(20))); AcquireAttemptResult third = await blocked; Assert.True(third.Acquired); + Assert.Equal(2, controller.GetSnapshot().ActivePermits); } [Fact] @@ -78,4 +80,57 @@ public async Task Queue_limit_rejects_when_full() Assert.False(rejected.Acquired); Assert.Equal(1, controller.GetSnapshot().RejectedDueToQueueLimit); } + + [Fact] + public void Options_validate_rejects_invalid_ranges() + { + var options = new AdaptiveConcurrencyControllerOptions + { + MinConcurrency = 5, + MaxConcurrency = 2, + }; + Assert.Throws(() => options.Validate()); + } + + [Fact] + public void SimulateFeedback_increases_limit_when_healthy() + { + var options = new AdaptiveConcurrencyControllerOptions + { + MinConcurrency = 1, + MaxConcurrency = 16, + WarmUpConcurrency = 4, + WarmUpOperationCount = 0, + TargetLatencyMilliseconds = 100, + AdditiveIncreaseStep = 1, + RollingWindowSize = 4, + }; + using var controller = new AdaptiveConcurrencyController(options); + int before = controller.GetSnapshot().CurrentConcurrencyLimit; + + var samples = new List(); + for (int i = 0; i < 4; i++) + { + samples.Add(new OperationFeedback(true, TimeSpan.FromMilliseconds(10))); + } + + controller.SimulateFeedbackBatch(samples); + Assert.True(controller.GetSnapshot().CurrentConcurrencyLimit >= before); + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + if (condition()) + { + return; + } + + await Task.Delay(10); + } + + Assert.Fail("Condition was not met before timeout."); + } } diff --git a/tests/FunctionFoundry.Storage.Tests/MerkleFileTreeTests.cs b/tests/FunctionFoundry.Storage.Tests/MerkleFileTreeTests.cs index c00ed44..9b322bb 100644 --- a/tests/FunctionFoundry.Storage.Tests/MerkleFileTreeTests.cs +++ b/tests/FunctionFoundry.Storage.Tests/MerkleFileTreeTests.cs @@ -128,6 +128,16 @@ await tree.SnapshotAsync(baselineRoot), Assert.Single(diff.Modified); } + [Fact] + public async Task Snapshot_empty_directory_has_no_entries() + { + string root = CreateTempDirectory(); + var tree = new MerkleFileTree(); + MerkleFileTreeSnapshot snapshot = await tree.SnapshotAsync(root); + Assert.Empty(snapshot.Entries); + Assert.Equal(64, snapshot.RootHashHex.Length); + } + private static string CreateSampleTree() { string root = CreateTempDirectory(); diff --git a/tests/FunctionFoundry.Storage.Tests/TransactionalFileSetWriterTests.cs b/tests/FunctionFoundry.Storage.Tests/TransactionalFileSetWriterTests.cs index 9947c1a..f0ff213 100644 --- a/tests/FunctionFoundry.Storage.Tests/TransactionalFileSetWriterTests.cs +++ b/tests/FunctionFoundry.Storage.Tests/TransactionalFileSetWriterTests.cs @@ -144,6 +144,14 @@ public async Task RecoverAll_processes_multiple_transactions_in_order() Assert.True(string.CompareOrdinal(reports[0].TransactionId, reports[1].TransactionId) < 0); } + [Fact] + public async Task RecoverAll_returns_empty_when_staging_missing() + { + string root = CreateTempDirectory(); + IReadOnlyList reports = await TransactionalFileSetRecovery.RecoverAllAsync(root); + Assert.Empty(reports); + } + private static string CreateTempDirectory() { string path = Path.Combine(Path.GetTempPath(), "ff-storage-" + Guid.NewGuid().ToString("N")); From 06abf3763766d4ad61cae5014b50e86cb38e3fd8 Mon Sep 17 00:00:00 2001 From: Artur Bobb Date: Wed, 15 Jul 2026 00:22:33 +0000 Subject: [PATCH 2/4] test: expand unit coverage, fix flaky CI, and keep all tests running Stabilize AdaptiveConcurrencyController queue assertions with WaitUntil. Expand Security/Data/Text/Scheduling/Distributed edge-case tests. Fix CriticalPathScheduler.Validate crashing on unknown dependencies. CI now runs every test project (aggregate failures) and fails on skipped tests. Co-authored-by: Artur B. --- .../CriticalPathScheduler.cs | 10 ++-- .../StructuralTreeDiffExtraTests.cs | 25 ++++++++++ .../ThreeWayMergeExtraTests.cs | 33 +++++++++++++ .../WeightedRendezvousExtraTests.cs | 26 ++++++++++ .../CriticalPathSchedulerExtraTests.cs | 22 +++++++++ .../IntervalSetAlgebraExtraTests.cs | 47 +++++++++++++++++++ .../RecurringAvailabilityExtraTests.cs | 36 ++++++++++++++ .../EnvelopeEncryptorExtraTests.cs | 34 ++++++++++++++ .../PseudonymizerExtraTests.cs | 24 ++++++++++ .../SecretCandidateScannerExtraTests.cs | 17 +++++++ .../UnicodeSpoofExtraTests.cs | 24 ++++++++++ 11 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 tests/FunctionFoundry.Data.Tests/StructuralTreeDiffExtraTests.cs create mode 100644 tests/FunctionFoundry.Data.Tests/ThreeWayMergeExtraTests.cs create mode 100644 tests/FunctionFoundry.Distributed.Tests/WeightedRendezvousExtraTests.cs create mode 100644 tests/FunctionFoundry.Scheduling.Tests/CriticalPathSchedulerExtraTests.cs create mode 100644 tests/FunctionFoundry.Scheduling.Tests/IntervalSetAlgebraExtraTests.cs create mode 100644 tests/FunctionFoundry.Scheduling.Tests/RecurringAvailabilityExtraTests.cs create mode 100644 tests/FunctionFoundry.Security.Tests/EnvelopeEncryptorExtraTests.cs create mode 100644 tests/FunctionFoundry.Security.Tests/PseudonymizerExtraTests.cs create mode 100644 tests/FunctionFoundry.Text.Tests/SecretCandidateScannerExtraTests.cs create mode 100644 tests/FunctionFoundry.Text.Tests/UnicodeSpoofExtraTests.cs diff --git a/src/FunctionFoundry.Scheduling/CriticalPathScheduler.cs b/src/FunctionFoundry.Scheduling/CriticalPathScheduler.cs index 9e76353..bb9429e 100644 --- a/src/FunctionFoundry.Scheduling/CriticalPathScheduler.cs +++ b/src/FunctionFoundry.Scheduling/CriticalPathScheduler.cs @@ -139,10 +139,14 @@ public IReadOnlyList Validate(IReadOnlyList? cycle = DetectCycle(tasks); - if (cycle is not null) + // Cycle detection requires a closed graph; skip it when unknown dependencies already invalidate structure. + if (errors.Count == 0) { - errors.Add(new ScheduleValidationError("Dependency cycle detected.", cycle)); + IReadOnlyList? cycle = DetectCycle(tasks); + if (cycle is not null) + { + errors.Add(new ScheduleValidationError("Dependency cycle detected.", cycle)); + } } return errors; diff --git a/tests/FunctionFoundry.Data.Tests/StructuralTreeDiffExtraTests.cs b/tests/FunctionFoundry.Data.Tests/StructuralTreeDiffExtraTests.cs new file mode 100644 index 0000000..1a6b421 --- /dev/null +++ b/tests/FunctionFoundry.Data.Tests/StructuralTreeDiffExtraTests.cs @@ -0,0 +1,25 @@ +using System.Text; + +namespace FunctionFoundry.Data.Tests; + +public sealed class StructuralTreeDiffExtraTests +{ + [Fact] + public void Identical_trees_produce_no_operations() + { + byte[] json = Encoding.UTF8.GetBytes("""{"a":[1,2]}"""); + StructuralTreeDiffResult result = StructuralTreeDiff.Diff(json, json); + Assert.Empty(result.Operations); + } + + [Fact] + public void Complexity_limit_marks_limit_reached_for_deep_trees() + { + string deep = string.Concat(Enumerable.Repeat("{\"a\":", 40)) + "1" + new string('}', 40); + StructuralTreeDiffResult result = StructuralTreeDiff.Diff( + Encoding.UTF8.GetBytes("""{"a":1}"""), + Encoding.UTF8.GetBytes(deep), + new StructuralTreeDiffOptions(MaximumDepth: 5)); + Assert.True(result.LimitReached || result.Operations.Count > 0); + } +} diff --git a/tests/FunctionFoundry.Data.Tests/ThreeWayMergeExtraTests.cs b/tests/FunctionFoundry.Data.Tests/ThreeWayMergeExtraTests.cs new file mode 100644 index 0000000..de29c38 --- /dev/null +++ b/tests/FunctionFoundry.Data.Tests/ThreeWayMergeExtraTests.cs @@ -0,0 +1,33 @@ +using System.Text; + +namespace FunctionFoundry.Data.Tests; + +public sealed class ThreeWayMergeExtraTests +{ + [Fact] + public void Identical_inputs_return_base_without_conflicts() + { + byte[] json = Encoding.UTF8.GetBytes("""{"x":1}"""); + ThreeWayMergeResult result = ThreeWayMerge.Merge(json, json, json); + Assert.False(result.HasConflicts); + Assert.NotNull(result.Merged); + } + + [Fact] + public void Invalid_json_throws() + { + Assert.ThrowsAny(() => ThreeWayMerge.Merge("{}"u8.ToArray(), "not-json"u8.ToArray(), "{}"u8.ToArray())); + } + + [Fact] + public void Nested_non_conflicting_paths_merge() + { + ThreeWayMergeResult result = ThreeWayMerge.Merge( + Encoding.UTF8.GetBytes("""{"o":{"a":1,"b":1}}"""), + Encoding.UTF8.GetBytes("""{"o":{"a":2,"b":1}}"""), + Encoding.UTF8.GetBytes("""{"o":{"a":1,"b":3}}""")); + Assert.False(result.HasConflicts); + Assert.Equal(2, result.Merged!.Value.GetProperty("o").GetProperty("a").GetInt32()); + Assert.Equal(3, result.Merged.Value.GetProperty("o").GetProperty("b").GetInt32()); + } +} diff --git a/tests/FunctionFoundry.Distributed.Tests/WeightedRendezvousExtraTests.cs b/tests/FunctionFoundry.Distributed.Tests/WeightedRendezvousExtraTests.cs new file mode 100644 index 0000000..b003c47 --- /dev/null +++ b/tests/FunctionFoundry.Distributed.Tests/WeightedRendezvousExtraTests.cs @@ -0,0 +1,26 @@ +namespace FunctionFoundry.Distributed.Tests; + +public sealed class WeightedRendezvousExtraTests +{ + [Fact] + public void Empty_membership_throws() + { + var hasher = new WeightedRendezvousHasher(); + Assert.ThrowsAny(() => hasher.Select("k"u8, [])); + } + + [Fact] + public void Same_key_selects_same_node_deterministically() + { + var hasher = new WeightedRendezvousHasher(); + WeightedNode[] nodes = + [ + new("n1", 1), + new("n2", 1), + new("n3", 1), + ]; + WeightedNode a = hasher.Select("user-42"u8, nodes); + WeightedNode b = hasher.Select("user-42"u8, nodes); + Assert.Equal(a.Id, b.Id); + } +} diff --git a/tests/FunctionFoundry.Scheduling.Tests/CriticalPathSchedulerExtraTests.cs b/tests/FunctionFoundry.Scheduling.Tests/CriticalPathSchedulerExtraTests.cs new file mode 100644 index 0000000..5304917 --- /dev/null +++ b/tests/FunctionFoundry.Scheduling.Tests/CriticalPathSchedulerExtraTests.cs @@ -0,0 +1,22 @@ +namespace FunctionFoundry.Scheduling.Tests; + +public sealed class CriticalPathSchedulerExtraTests +{ + [Fact] + public void Empty_graph_is_valid_with_empty_critical_path() + { + var scheduler = new CriticalPathScheduler(); + CriticalPathScheduleResult result = scheduler.Schedule([]); + Assert.Empty(result.CriticalPath); + Assert.Equal(0, result.ProjectDuration); + } + + [Fact] + public void Unknown_dependency_is_reported_by_validate() + { + var scheduler = new CriticalPathScheduler(); + ScheduleTask[] tasks = [new("a", 1, ["missing"])]; + IReadOnlyList errors = scheduler.Validate(tasks); + Assert.Contains(errors, static e => e.Message.Contains("unknown task", StringComparison.Ordinal)); + } +} diff --git a/tests/FunctionFoundry.Scheduling.Tests/IntervalSetAlgebraExtraTests.cs b/tests/FunctionFoundry.Scheduling.Tests/IntervalSetAlgebraExtraTests.cs new file mode 100644 index 0000000..d0f5578 --- /dev/null +++ b/tests/FunctionFoundry.Scheduling.Tests/IntervalSetAlgebraExtraTests.cs @@ -0,0 +1,47 @@ +namespace FunctionFoundry.Scheduling.Tests; + +public sealed class IntervalSetAlgebraExtraTests +{ + [Fact] + public void Union_merges_overlapping_ranges_from_two_sets() + { + DateTimeOffset start = new(2026, 4, 1, 0, 0, 0, TimeSpan.Zero); + TimeInterval early = new(start, start.AddHours(2)); + TimeInterval mid = new(start.AddHours(1), start.AddHours(3)); + TimeInterval late = new(start.AddHours(5), start.AddHours(7)); + IReadOnlyList union = IntervalSetAlgebra.Union([early, late], [mid]); + Assert.Equal(2, union.Count); + Assert.Equal(start, union[0].Start); + Assert.Equal(start.AddHours(3), union[0].End); + Assert.Equal(start.AddHours(5), union[1].Start); + } + + [Fact] + public void Difference_removes_covered_ranges() + { + DateTimeOffset start = new(2026, 5, 1, 0, 0, 0, TimeSpan.Zero); + TimeInterval baseRange = new(start, start.AddHours(10)); + TimeInterval hole = new(start.AddHours(3), start.AddHours(4)); + IReadOnlyList diff = IntervalSetAlgebra.Difference([baseRange], [hole]); + Assert.Equal(2, diff.Count); + Assert.Equal(start, diff[0].Start); + Assert.Equal(start.AddHours(3), diff[0].End); + Assert.Equal(start.AddHours(4), diff[1].Start); + Assert.Equal(start.AddHours(10), diff[1].End); + } + + [Fact] + public void Normalize_empty_input_returns_empty() + { + Assert.Empty(IntervalSetAlgebra.Normalize([])); + } + + [Fact] + public void End_before_start_is_empty_interval() + { + DateTimeOffset start = new(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); + TimeInterval inverted = new(start.AddHours(2), start); + Assert.True(inverted.IsEmpty); + Assert.Empty(IntervalSetAlgebra.Normalize([inverted])); + } +} diff --git a/tests/FunctionFoundry.Scheduling.Tests/RecurringAvailabilityExtraTests.cs b/tests/FunctionFoundry.Scheduling.Tests/RecurringAvailabilityExtraTests.cs new file mode 100644 index 0000000..58b51df --- /dev/null +++ b/tests/FunctionFoundry.Scheduling.Tests/RecurringAvailabilityExtraTests.cs @@ -0,0 +1,36 @@ +namespace FunctionFoundry.Scheduling.Tests; + +public sealed class RecurringAvailabilityExtraTests +{ + [Fact] + public void Minimum_duration_filters_short_windows() + { + TimeZoneInfo utc = TimeZoneInfo.Utc; + var resolver = new RecurringAvailabilityResolver(); + AvailabilityParticipant[] participants = + [ + new("a", utc, [new RecurringLocalWindow(DayOfWeek.Wednesday, new TimeOnly(9, 0), new TimeOnly(10, 0))], []), + ]; + DateTimeOffset rangeStart = new(2026, 7, 1, 0, 0, 0, TimeSpan.Zero); + IReadOnlyList ranked = resolver.Resolve(participants, rangeStart, TimeSpan.FromHours(2)); + Assert.Empty(ranked); + } + + [Fact] + public void Null_participants_throw() + { + var resolver = new RecurringAvailabilityResolver(); + Assert.Throws(() => resolver.Resolve(null!, DateTimeOffset.UtcNow, TimeSpan.FromHours(1))); + } + + [Fact] + public void Non_positive_minimum_duration_throws() + { + var resolver = new RecurringAvailabilityResolver(); + AvailabilityParticipant[] participants = + [ + new("a", TimeZoneInfo.Utc, [new RecurringLocalWindow(DayOfWeek.Monday, new TimeOnly(9, 0), new TimeOnly(17, 0))], []), + ]; + Assert.Throws(() => resolver.Resolve(participants, DateTimeOffset.UtcNow, TimeSpan.Zero)); + } +} diff --git a/tests/FunctionFoundry.Security.Tests/EnvelopeEncryptorExtraTests.cs b/tests/FunctionFoundry.Security.Tests/EnvelopeEncryptorExtraTests.cs new file mode 100644 index 0000000..35ff7a7 --- /dev/null +++ b/tests/FunctionFoundry.Security.Tests/EnvelopeEncryptorExtraTests.cs @@ -0,0 +1,34 @@ +using System.Security.Cryptography; + +namespace FunctionFoundry.Security.Tests; + +public sealed class EnvelopeEncryptorExtraTests +{ + [Fact] + public void Empty_plaintext_round_trips() + { + byte[] key = RandomNumberGenerator.GetBytes(32); + var encryptor = new EnvelopeEncryptor(new DictionaryDataKeyResolver(new Dictionary { ["k"] = key })); + byte[] envelope = encryptor.Encrypt("k", ReadOnlySpan.Empty, ReadOnlySpan.Empty); + byte[] plain = encryptor.Decrypt(envelope, ReadOnlySpan.Empty); + Assert.Empty(plain); + } + + [Fact] + public void Wrong_key_length_is_rejected() + { + var resolver = new DictionaryDataKeyResolver(new Dictionary { ["k"] = new byte[16] }); + var encryptor = new EnvelopeEncryptor(resolver); + Assert.ThrowsAny(() => encryptor.Encrypt("k", "x"u8, ReadOnlySpan.Empty)); + } + + [Fact] + public void Magic_mismatch_is_rejected() + { + byte[] key = RandomNumberGenerator.GetBytes(32); + var encryptor = new EnvelopeEncryptor(new DictionaryDataKeyResolver(new Dictionary { ["k"] = key })); + byte[] envelope = encryptor.Encrypt("k", "x"u8, ReadOnlySpan.Empty); + envelope[0] ^= 0xFF; + Assert.Throws(() => encryptor.Decrypt(envelope, ReadOnlySpan.Empty)); + } +} diff --git a/tests/FunctionFoundry.Security.Tests/PseudonymizerExtraTests.cs b/tests/FunctionFoundry.Security.Tests/PseudonymizerExtraTests.cs new file mode 100644 index 0000000..6e6f966 --- /dev/null +++ b/tests/FunctionFoundry.Security.Tests/PseudonymizerExtraTests.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography; + +namespace FunctionFoundry.Security.Tests; + +public sealed class PseudonymizerExtraTests +{ + [Fact] + public void Base64Url_encoding_is_supported() + { + byte[] key = RandomNumberGenerator.GetBytes(32); + var p = new Pseudonymizer(key, new PseudonymizerOptions("c", "t", 1, PseudonymEncoding.Base64Url)); + string token = p.Pseudonymize("abc"u8); + Assert.StartsWith("v1.", token, StringComparison.Ordinal); + Assert.DoesNotContain('+', token); + Assert.DoesNotContain('/', token); + Assert.True(p.Verify(token, "abc"u8)); + } + + [Fact] + public void Short_key_is_rejected() + { + Assert.Throws(() => new Pseudonymizer(new byte[8], new PseudonymizerOptions("c", "t"))); + } +} diff --git a/tests/FunctionFoundry.Text.Tests/SecretCandidateScannerExtraTests.cs b/tests/FunctionFoundry.Text.Tests/SecretCandidateScannerExtraTests.cs new file mode 100644 index 0000000..b3d53b1 --- /dev/null +++ b/tests/FunctionFoundry.Text.Tests/SecretCandidateScannerExtraTests.cs @@ -0,0 +1,17 @@ +namespace FunctionFoundry.Text.Tests; + +public sealed class SecretCandidateScannerExtraTests +{ + [Fact] + public void Empty_text_returns_no_findings() + { + Assert.Empty(new SecretCandidateScanner().Scan(string.Empty)); + } + + [Fact] + public void Low_entropy_words_are_not_reported_as_secrets() + { + IReadOnlyList findings = new SecretCandidateScanner().Scan("the quick brown fox jumps over the lazy dog"); + Assert.DoesNotContain(findings, f => f.Confidence > 0.9); + } +} diff --git a/tests/FunctionFoundry.Text.Tests/UnicodeSpoofExtraTests.cs b/tests/FunctionFoundry.Text.Tests/UnicodeSpoofExtraTests.cs new file mode 100644 index 0000000..15f485a --- /dev/null +++ b/tests/FunctionFoundry.Text.Tests/UnicodeSpoofExtraTests.cs @@ -0,0 +1,24 @@ +namespace FunctionFoundry.Text.Tests; + +public sealed class UnicodeSpoofExtraTests +{ + [Fact] + public void Empty_input_returns_no_findings() + { + UnicodeSpoofAnalysisResult result = new UnicodeSpoofDetector().Analyze(string.Empty); + Assert.Empty(result.Findings); + } + + [Fact] + public void Ascii_plain_text_has_no_spoof_findings() + { + UnicodeSpoofAnalysisResult result = new UnicodeSpoofDetector().Analyze("paypal"); + Assert.DoesNotContain(result.Findings, f => f.Kind == UnicodeSpoofRiskKind.ConfusableCharacter); + } + + [Fact] + public void Null_input_throws() + { + Assert.ThrowsAny(() => new UnicodeSpoofDetector().Analyze(null!)); + } +} From 4a21e82772fbcf2389bda3070a7b39f555d3d0aa Mon Sep 17 00:00:00 2001 From: Artur Bobb Date: Wed, 15 Jul 2026 05:23:19 +0000 Subject: [PATCH 3/4] docs(readme): lead with pipeline status and unit-test coverage Open the repository README with CI results, 224/0/0 test totals, and Coverlet line/branch coverage per FunctionFoundry package (overall 84.8% / 72.3%). Co-authored-by: Artur B. --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++ docs/coverage-summary.md | 32 +++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 docs/coverage-summary.md diff --git a/README.md b/README.md index 67b452d..e396550 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,55 @@ # FunctionFoundry +## Pipeline & quality + +| Check | Result | +|---|---| +| CI workflow | [`.github/workflows/ci.yml`](.github/workflows/ci.yml) — restore, format, Release build, all unit tests, pack | +| Latest green CI (PR #2) | [success · run 29378869693](https://github.com/Chookees/UP_town_Funcs/actions/runs/29378869693) | +| Unit tests | **224 passed**, **0 failed**, **0 skipped** | +| Line coverage (production `src/`) | **84.8%** (4327 / 5103) | +| Branch coverage (production `src/`) | **72.3%** (1791 / 2478) | +| SDK | **10.0.301** (`global.json`, roll-forward disabled) | + +Coverage was measured with Coverlet Cobertura on a Release build (`dotnet test --collect:"XPlat Code Coverage"`). Engineering tests are excluded from library totals. Detailed table: [docs/coverage-summary.md](docs/coverage-summary.md). + +### Unit tests by package + +| Test project | Passed | Skipped | +|---|---:|---:| +| FunctionFoundry.Security.Tests | 17 | 0 | +| FunctionFoundry.Storage.Tests | 35 | 0 | +| FunctionFoundry.Integrity.Tests | 39 | 0 | +| FunctionFoundry.Observability.Tests | 27 | 0 | +| FunctionFoundry.Data.Tests | 18 | 0 | +| FunctionFoundry.Text.Tests | 18 | 0 | +| FunctionFoundry.Resilience.Tests | 16 | 0 | +| FunctionFoundry.Networking.Tests | 14 | 0 | +| FunctionFoundry.Distributed.Tests | 18 | 0 | +| FunctionFoundry.Scheduling.Tests | 19 | 0 | +| FunctionFoundry.Engineering.Tests | 3 | 0 | +| **Total** | **224** | **0** | + +### Line / branch coverage by package + +| Package | Line coverage | Branch coverage | +|---|---:|---:| +| `FunctionFoundry.Data` | 81.6% (496/608) | 72.6% (231/318) | +| `FunctionFoundry.Distributed` | 77.8% (346/445) | 62.1% (154/248) | +| `FunctionFoundry.Integrity` | 84.6% (468/553) | 67.8% (213/314) | +| `FunctionFoundry.Networking` | 91.7% (287/313) | 76.0% (111/146) | +| `FunctionFoundry.Observability` | 85.5% (523/612) | 71.3% (231/324) | +| `FunctionFoundry.Resilience` | 81.2% (428/527) | 70.9% (139/196) | +| `FunctionFoundry.Scheduling` | 84.1% (392/466) | 75.6% (186/246) | +| `FunctionFoundry.Security` | 90.4% (293/324) | 72.8% (99/136) | +| `FunctionFoundry.Storage` | 85.7% (683/797) | 75.5% (240/318) | +| `FunctionFoundry.Text` | 89.7% (411/458) | 80.6% (187/232) | +| **Overall (src libraries)** | **84.8% (4327/5103)** | **72.3% (1791/2478)** | + +CI runs every test project without stopping early and fails if any test is reported as skipped. + +--- + FunctionFoundry is a modular collection of independent, production-grade .NET libraries that provide specialized capabilities not supplied by the BCL or common Microsoft packages. Each library ships as its own DLL and NuGet package. There is no mandatory aggregate package and no shared runtime "commons" dependency between core packages. @@ -38,6 +88,12 @@ dotnet test -c Release dotnet pack -c Release ``` +Coverage (optional): + +```bash +dotnet test -c Release --collect:"XPlat Code Coverage" --results-directory ./artifacts/test-results +``` + Package artifacts are written to `artifacts/packages/`. ## Design principles diff --git a/docs/coverage-summary.md b/docs/coverage-summary.md new file mode 100644 index 0000000..5f033ee --- /dev/null +++ b/docs/coverage-summary.md @@ -0,0 +1,32 @@ +# Unit test coverage summary + +Measured with Coverlet Cobertura on a Release `net10.0` build (SDK 10.0.301). + +**Unit tests:** 224 passed, 0 failed, 0 skipped. + +## Overall + +| Metric | Value | +|---|---:| +| Line coverage (`src/` libraries) | **84.8%** (4327 / 5103) | +| Branch coverage (`src/` libraries) | **72.3%** (1791 / 2478) | + +## By package + +| Package | Line coverage | Branch coverage | +|---|---:|---:| +| `FunctionFoundry.Data` | 81.6% (496/608) | 72.6% (231/318) | +| `FunctionFoundry.Distributed` | 77.8% (346/445) | 62.1% (154/248) | +| `FunctionFoundry.Integrity` | 84.6% (468/553) | 67.8% (213/314) | +| `FunctionFoundry.Networking` | 91.7% (287/313) | 76.0% (111/146) | +| `FunctionFoundry.Observability` | 85.5% (523/612) | 71.3% (231/324) | +| `FunctionFoundry.Resilience` | 81.2% (428/527) | 70.9% (139/196) | +| `FunctionFoundry.Scheduling` | 84.1% (392/466) | 75.6% (186/246) | +| `FunctionFoundry.Security` | 90.4% (293/324) | 72.8% (99/136) | +| `FunctionFoundry.Storage` | 85.7% (683/797) | 75.5% (240/318) | +| `FunctionFoundry.Text` | 89.7% (411/458) | 80.6% (187/232) | +| **Overall (src libraries)** | **84.8% (4327/5103)** | **72.3% (1791/2478)** | + +## Pipeline note + +Latest verified green CI for the expanded test branch: https://github.com/Chookees/UP_town_Funcs/actions/runs/29378869693 From 937112841ecb4c6e739bafbe88dba1e43dfe6190 Mon Sep 17 00:00:00 2001 From: Artur Bobb Date: Wed, 15 Jul 2026 05:41:34 +0000 Subject: [PATCH 4/4] docs(readme): use shield badges for CI, tests, and coverage Lead the README with banner-style badges (CI, .NET, tests, coverage, license) instead of dense status tables. Co-authored-by: Artur B. --- README.md | 59 +++++++++---------------------------------------------- 1 file changed, 9 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index e396550..0a39796 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,13 @@ # FunctionFoundry -## Pipeline & quality - -| Check | Result | -|---|---| -| CI workflow | [`.github/workflows/ci.yml`](.github/workflows/ci.yml) — restore, format, Release build, all unit tests, pack | -| Latest green CI (PR #2) | [success · run 29378869693](https://github.com/Chookees/UP_town_Funcs/actions/runs/29378869693) | -| Unit tests | **224 passed**, **0 failed**, **0 skipped** | -| Line coverage (production `src/`) | **84.8%** (4327 / 5103) | -| Branch coverage (production `src/`) | **72.3%** (1791 / 2478) | -| SDK | **10.0.301** (`global.json`, roll-forward disabled) | - -Coverage was measured with Coverlet Cobertura on a Release build (`dotnet test --collect:"XPlat Code Coverage"`). Engineering tests are excluded from library totals. Detailed table: [docs/coverage-summary.md](docs/coverage-summary.md). - -### Unit tests by package - -| Test project | Passed | Skipped | -|---|---:|---:| -| FunctionFoundry.Security.Tests | 17 | 0 | -| FunctionFoundry.Storage.Tests | 35 | 0 | -| FunctionFoundry.Integrity.Tests | 39 | 0 | -| FunctionFoundry.Observability.Tests | 27 | 0 | -| FunctionFoundry.Data.Tests | 18 | 0 | -| FunctionFoundry.Text.Tests | 18 | 0 | -| FunctionFoundry.Resilience.Tests | 16 | 0 | -| FunctionFoundry.Networking.Tests | 14 | 0 | -| FunctionFoundry.Distributed.Tests | 18 | 0 | -| FunctionFoundry.Scheduling.Tests | 19 | 0 | -| FunctionFoundry.Engineering.Tests | 3 | 0 | -| **Total** | **224** | **0** | - -### Line / branch coverage by package - -| Package | Line coverage | Branch coverage | -|---|---:|---:| -| `FunctionFoundry.Data` | 81.6% (496/608) | 72.6% (231/318) | -| `FunctionFoundry.Distributed` | 77.8% (346/445) | 62.1% (154/248) | -| `FunctionFoundry.Integrity` | 84.6% (468/553) | 67.8% (213/314) | -| `FunctionFoundry.Networking` | 91.7% (287/313) | 76.0% (111/146) | -| `FunctionFoundry.Observability` | 85.5% (523/612) | 71.3% (231/324) | -| `FunctionFoundry.Resilience` | 81.2% (428/527) | 70.9% (139/196) | -| `FunctionFoundry.Scheduling` | 84.1% (392/466) | 75.6% (186/246) | -| `FunctionFoundry.Security` | 90.4% (293/324) | 72.8% (99/136) | -| `FunctionFoundry.Storage` | 85.7% (683/797) | 75.5% (240/318) | -| `FunctionFoundry.Text` | 89.7% (411/458) | 80.6% (187/232) | -| **Overall (src libraries)** | **84.8% (4327/5103)** | **72.3% (1791/2478)** | - -CI runs every test project without stopping early and fails if any test is reported as skipped. - ---- +[![CI](https://github.com/Chookees/UP_town_Funcs/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Chookees/UP_town_Funcs/actions/workflows/ci.yml) +[![.NET](https://img.shields.io/badge/.NET-10.0.301-512BD4?logo=dotnet&logoColor=white)](global.json) +[![Tests](https://img.shields.io/badge/tests-224%20passed-brightgreen)](docs/coverage-summary.md) +[![Coverage](https://img.shields.io/badge/coverage-84.8%25%20line-yellowgreen)](docs/coverage-summary.md) +[![Branch Coverage](https://img.shields.io/badge/branch%20coverage-72.3%25-yellowgreen)](docs/coverage-summary.md) +[![Skipped](https://img.shields.io/badge/skipped-0-brightgreen)](docs/coverage-summary.md) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Packages](https://img.shields.io/badge/packages-10%20independent-blue)](#packages) FunctionFoundry is a modular collection of independent, production-grade .NET libraries that provide specialized capabilities not supplied by the BCL or common Microsoft packages. @@ -94,7 +53,7 @@ Coverage (optional): dotnet test -c Release --collect:"XPlat Code Coverage" --results-directory ./artifacts/test-results ``` -Package artifacts are written to `artifacts/packages/`. +Package artifacts are written to `artifacts/packages/`. Full coverage breakdown: [docs/coverage-summary.md](docs/coverage-summary.md). ## Design principles