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/CHANGELOG.md b/CHANGELOG.md
index fad2ed5..c9c0a01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to FunctionFoundry packages are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Changed
+
+* Relicensed from MIT to **Apache License 2.0** with an attribution `NOTICE` file. Free use remains allowed; FunctionFoundry must be credited when used or redistributed.
+
## [1.0.0] - 2026-07-14
### Added
diff --git a/Directory.Build.props b/Directory.Build.props
index 47be5cf..6657629 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,7 @@
FunctionFoundry Contributors
FunctionFoundry
Copyright (c) FunctionFoundry Contributors
- MIT
+ Apache-2.0
https://github.com/Chookees/UP_town_Funcs
https://github.com/Chookees/UP_town_Funcs
git
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 4208be2..5b084c2 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -9,6 +9,14 @@
+
+
+
+
+
+
+
+
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.Engineering.Tests/FunctionFoundry.Engineering.Tests.csproj b/tests/FunctionFoundry.Engineering.Tests/FunctionFoundry.Engineering.Tests.csproj
index 30dcdb1..aa36990 100644
--- a/tests/FunctionFoundry.Engineering.Tests/FunctionFoundry.Engineering.Tests.csproj
+++ b/tests/FunctionFoundry.Engineering.Tests/FunctionFoundry.Engineering.Tests.csproj
@@ -15,6 +15,10 @@
+
+
+
+
diff --git a/tests/FunctionFoundry.Engineering.Tests/SdkPinningTests.cs b/tests/FunctionFoundry.Engineering.Tests/SdkPinningTests.cs
index 98aa468..848b9e4 100644
--- a/tests/FunctionFoundry.Engineering.Tests/SdkPinningTests.cs
+++ b/tests/FunctionFoundry.Engineering.Tests/SdkPinningTests.cs
@@ -36,6 +36,19 @@ public void Forbidden_aggregate_package_names_are_blocked_by_targets()
Assert.Contains(".Helpers", content, StringComparison.Ordinal);
}
+ [Fact]
+ public void Repository_uses_Apache_2_0_with_attribution_notice()
+ {
+ string props = File.ReadAllText(FindRepoFile("Directory.Build.props"));
+ Assert.Contains("Apache-2.0", props, StringComparison.Ordinal);
+ string license = File.ReadAllText(FindRepoFile("LICENSE"));
+ Assert.Contains("Apache License", license, StringComparison.Ordinal);
+ Assert.Contains("Version 2.0", license, StringComparison.Ordinal);
+ string notice = File.ReadAllText(FindRepoFile("NOTICE"));
+ Assert.Contains("FunctionFoundry", notice, StringComparison.Ordinal);
+ Assert.Contains("Attribution", notice, StringComparison.OrdinalIgnoreCase);
+ }
+
private static string FindRepoFile(string relativePath)
{
string? dir = AppContext.BaseDirectory;
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.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.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"));
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!));
+ }
+}