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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# FunctionFoundry

[![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.

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.
Expand Down Expand Up @@ -38,7 +47,13 @@ dotnet test -c Release
dotnet pack -c Release
```

Package artifacts are written to `artifacts/packages/`.
Coverage (optional):

```bash
dotnet test -c Release --collect:"XPlat Code Coverage" --results-directory ./artifacts/test-results
```

Package artifacts are written to `artifacts/packages/`. Full coverage breakdown: [docs/coverage-summary.md](docs/coverage-summary.md).

## Design principles

Expand Down
32 changes: 32 additions & 0 deletions docs/coverage-summary.md
Original file line number Diff line number Diff line change
@@ -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
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());
}
}
Loading
Loading