From 1cd15f06625b75914618222d201d95b646cb97d8 Mon Sep 17 00:00:00 2001 From: Artur Bobb Date: Wed, 15 Jul 2026 00:18:30 +0000 Subject: [PATCH 1/2] 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/2] 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!)); + } +}