Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
10 changes: 7 additions & 3 deletions src/FunctionFoundry.Scheduling/CriticalPathScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,14 @@ public IReadOnlyList<ScheduleValidationError> Validate(IReadOnlyList<ScheduleTas
}
}

IReadOnlyList<string>? 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<string>? cycle = DetectCycle(tasks);
if (cycle is not null)
{
errors.Add(new ScheduleValidationError("Dependency cycle detected.", cycle));
}
}

return errors;
Expand Down
25 changes: 25 additions & 0 deletions tests/FunctionFoundry.Data.Tests/StructuralTreeDiffExtraTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
33 changes: 33 additions & 0 deletions tests/FunctionFoundry.Data.Tests/ThreeWayMergeExtraTests.cs
Original file line number Diff line number Diff line change
@@ -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<Exception>(() => 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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace FunctionFoundry.Distributed.Tests;

public sealed class WeightedRendezvousExtraTests
{
[Fact]
public void Empty_membership_throws()
{
var hasher = new WeightedRendezvousHasher();
Assert.ThrowsAny<ArgumentException>(() => 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);
}
}
9 changes: 9 additions & 0 deletions tests/FunctionFoundry.Integrity.Tests/HashChainTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
8 changes: 8 additions & 0 deletions tests/FunctionFoundry.Integrity.Tests/MerkleTreeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentOutOfRangeException>(() => tree.CreateProof(1));
Assert.Throws<ArgumentOutOfRangeException>(() => tree.CreateProof(-1));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Stream>
{
["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);
}
}
14 changes: 14 additions & 0 deletions tests/FunctionFoundry.Networking.Tests/MirrorSelectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentException>(() => 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<ArgumentOutOfRangeException>(() => options.Validate());
}
}
17 changes: 17 additions & 0 deletions tests/FunctionFoundry.Networking.Tests/TransferPlanTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentOutOfRangeException>(() => TransferPlan.Create(-1, 16));
Assert.Throws<ArgumentOutOfRangeException>(() => 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentOutOfRangeException>(() => options.Validate());
}
}
14 changes: 14 additions & 0 deletions tests/FunctionFoundry.Observability.Tests/BurstCoalescerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentOutOfRangeException>(() => options.Validate());
}

[Fact]
public void Record_throws_for_blank_fingerprint()
{
var coalescer = new BurstCoalescer();
Assert.Throws<ArgumentException>(() => coalescer.Record(" ", "payload"));
}

private sealed class MutableClock(DateTimeOffset start)
{
private DateTimeOffset _now = start;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentNullException>(() => fingerprinter.Fingerprint(null!));
}

[Fact]
public void Options_validate_rejects_negative_inner_exception_depth()
{
var options = new EventFingerprinterOptions { MaxInnerExceptionDepth = -1 };
Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@ public async Task Acquire_and_release_respects_max_concurrency()
Assert.True(first.Acquired);
Assert.True(second.Acquired);

Task<AcquireAttemptResult> blocked = controller.TryAcquireAsync(TimeSpan.FromMilliseconds(200)).AsTask();
await Task.Delay(50);
Task<AcquireAttemptResult> 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);

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]
Expand Down Expand Up @@ -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<ArgumentOutOfRangeException>(() => 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<OperationFeedback>();
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<bool> 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.");
}
}
Original file line number Diff line number Diff line change
@@ -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<ScheduleValidationError> errors = scheduler.Validate(tasks);
Assert.Contains(errors, static e => e.Message.Contains("unknown task", StringComparison.Ordinal));
}
}
Loading
Loading