9 Commits

Author SHA1 Message Date
just e37e9b65c3 documentation update
.NET Test / .NET tests (push) Successful in 1m58s
.NET Publish / publish (push) Successful in 58s
2026-07-10 22:16:20 +04:00
just 8e961352d2 fixed seqid test, added timestamp extraction
.NET Test / .NET tests (push) Successful in 1m55s
2026-07-10 22:06:37 +04:00
just 566c813e8d base32 refactoring
.NET Test / .NET tests (push) Failing after 1m54s
2026-07-10 21:53:37 +04:00
just d11c74e5d6 setup multiple dotnet versions
.NET Test / .NET tests (push) Successful in 2m44s
.NET Publish / publish (push) Successful in 51s
2025-11-11 21:53:10 +04:00
just 85721b9769 added net10.0 support
.NET Test / .NET tests (push) Failing after 1m0s
2025-11-11 21:48:35 +04:00
just f7484b35e2 test pipeline tweaks
.NET Test / .NET 8.0 (push) Failing after 11m49s
.NET Test / .NET 9.0 (push) Successful in 12m3s
2025-11-11 19:11:12 +04:00
just a490a9b328 fix dotnet build command
.NET Test / .NET 8.0 (push) Failing after 1m13s
.NET Test / .NET 9.0 (push) Failing after 1m20s
2025-11-11 19:04:29 +04:00
just 034a88ba8f pipeline fix
.NET Test / .NET 9.0 (push) Failing after 42s
.NET Test / .NET 8.0 (push) Has been cancelled
2025-11-11 19:01:52 +04:00
just e28fc62b31 switch from FluentAssertions to Shouldly
.NET Test / test (8.x) (push) Failing after 1m21s
.NET Test / test (9.x) (push) Failing after 1m25s
2025-11-11 17:57:10 +04:00
31 changed files with 823 additions and 185 deletions
+6 -3
View File
@@ -10,13 +10,16 @@ jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v5
- name: Setup .NET - name: Setup .NET
uses: https://github.com/actions/setup-dotnet@v3 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.x dotnet-version: 10.x
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore Core/Core.csproj run: dotnet restore Core/Core.csproj
+30 -10
View File
@@ -6,6 +6,7 @@ on:
tags-ignore: tags-ignore:
- '**' - '**'
paths-ignore: paths-ignore:
- 'LICENSE'
- 'README.md' - 'README.md'
- '.gitea/workflows/publish-*.yaml' - '.gitea/workflows/publish-*.yaml'
pull_request: pull_request:
@@ -14,28 +15,47 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: .NET tests
env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v5
- name: Setup .NET - name: Setup .NET
uses: https://github.com/actions/setup-dotnet@v3 uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: 9.x dotnet-version: |
8.0.x
9.0.x
10.0.x
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore --disable-parallel
- name: Build - name: Build .NET 10.0
run: dotnet build --no-restore run: dotnet build --no-restore --framework net10.0 --configuration Release ./Core.Tests/Core.Tests.csproj
- name: Test - name: Build .NET 9.0
run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults-9.x" run: dotnet build --no-restore --framework net9.0 --configuration Release ./Core.Tests/Core.Tests.csproj
- name: Build .NET 8.0
run: dotnet build --no-restore --framework net8.0 --configuration Release ./Core.Tests/Core.Tests.csproj
- name: Test .NET 10.0
run: dotnet run --no-build --framework net10.0 --configuration Release --project ./Core.Tests/Core.Tests.csproj -- -trx TestResults/results-net10.trx
- name: Test .NET 9.0
run: dotnet run --no-build --framework net9.0 --configuration Release --project ./Core.Tests/Core.Tests.csproj -- -trx TestResults/results-net9.trx
- name: Test .NET 8.0
run: dotnet run --no-build --framework net8.0 --configuration Release --project ./Core.Tests/Core.Tests.csproj -- -trx TestResults/results-net8.trx
- name: Upload dotnet test results - name: Upload dotnet test results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: dotnet-results-9.x name: test-results
path: TestResults-9.x path: TestResults
if: ${{ always() }} if: ${{ always() }}
retention-days: 30 retention-days: 30
+5 -2
View File
@@ -1,4 +1,7 @@
{ {
"dotnet.defaultSolution": "JustDotNet.Core.sln", "dotnet.defaultSolution": "JustDotNet.Core.slnx",
"dotnetAcquisitionExtension.enableTelemetry": false "omnisharp.enableEditorConfigSupport": true,
"dotnetAcquisitionExtension.enableTelemetry": false,
"dotnet.testWindow.useTestingPlatformProtocol": true,
"dotnet.formatting.organizeImportsOnFormat": true
} }
+93 -16
View File
@@ -3,11 +3,31 @@ namespace Just.Core.Tests.Base32Conversions;
public class Decode public class Decode
{ {
[Theory] [Theory]
[InlineData(15243)] [InlineData(15243, Base32EncodeOptions.None)]
[InlineData(812010)] [InlineData(15243, Base32EncodeOptions.LowerCase)]
[InlineData(97331334)] [InlineData(15243, Base32EncodeOptions.NoPadding)]
[InlineData(20354)] [InlineData(15243, Base32EncodeOptions.LowerCaseNoPadding)]
public void WhenEncodedToString_ShouldBeDecodedToTheSameByteArray(int seed) [InlineData(812010, Base32EncodeOptions.None)]
[InlineData(812010, Base32EncodeOptions.LowerCase)]
[InlineData(812010, Base32EncodeOptions.NoPadding)]
[InlineData(812010, Base32EncodeOptions.LowerCaseNoPadding)]
[InlineData(97331334, Base32EncodeOptions.None)]
[InlineData(97331334, Base32EncodeOptions.LowerCase)]
[InlineData(97331334, Base32EncodeOptions.NoPadding)]
[InlineData(97331334, Base32EncodeOptions.LowerCaseNoPadding)]
[InlineData(20354, Base32EncodeOptions.None)]
[InlineData(20354, Base32EncodeOptions.LowerCase)]
[InlineData(20354, Base32EncodeOptions.NoPadding)]
[InlineData(20354, Base32EncodeOptions.LowerCaseNoPadding)]
[InlineData(33409, Base32EncodeOptions.None)]
[InlineData(33409, Base32EncodeOptions.LowerCase)]
[InlineData(33409, Base32EncodeOptions.NoPadding)]
[InlineData(33409, Base32EncodeOptions.LowerCaseNoPadding)]
[InlineData(59113, Base32EncodeOptions.None)]
[InlineData(59113, Base32EncodeOptions.LowerCase)]
[InlineData(59113, Base32EncodeOptions.NoPadding)]
[InlineData(59113, Base32EncodeOptions.LowerCaseNoPadding)]
public void WhenEncodedToString_ShouldBeDecodedToTheSameByteArray(int seed, Base32EncodeOptions options)
{ {
var rng = new Random(seed); var rng = new Random(seed);
@@ -16,25 +36,33 @@ public class Decode
var testBytes = new byte[i]; var testBytes = new byte[i];
rng.NextBytes(testBytes); rng.NextBytes(testBytes);
var resultString = Base32.Encode(testBytes); var resultString = Base32.Encode(testBytes, options);
var resultBytes = Base32.Decode(resultString); var resultBytes = Base32.Decode(resultString);
resultBytes.Should().BeEquivalentTo(testBytes); resultBytes.ShouldBeEquivalentTo(testBytes);
} }
} }
[Theory] [Theory]
[InlineData("FG4M3ZQM3TVWDMBUP5L7N7V3JS7KBM2E", new byte[] { 0x29, 0xb8, 0xcd, 0xe6, 0x0c, 0xdc, 0xeb, 0x61, 0xb0, 0x34, 0x7f, 0x57, 0xf6, 0xfe, 0xbb, 0x4c, 0xbe, 0xa0, 0xb3, 0x44, })] [InlineData("FG4M3ZQM3TVWDMBUP5L7N7V3JS7KBM2E", new byte[] { 0x29, 0xb8, 0xcd, 0xe6, 0x0c, 0xdc, 0xeb, 0x61, 0xb0, 0x34, 0x7f, 0x57, 0xf6, 0xfe, 0xbb, 0x4c, 0xbe, 0xa0, 0xb3, 0x44, })]
[InlineData("fg4m3zqm3tvwdmbup5l7n7v3js7kbm2e", new byte[] { 0x29, 0xb8, 0xcd, 0xe6, 0x0c, 0xdc, 0xeb, 0x61, 0xb0, 0x34, 0x7f, 0x57, 0xf6, 0xfe, 0xbb, 0x4c, 0xbe, 0xa0, 0xb3, 0x44, })]
[InlineData("WXYEOQUZULMCY6ZQTDOLTRUZZMKQ====", new byte[] { 0xb5, 0xf0, 0x47, 0x42, 0x99, 0xa2, 0xd8, 0x2c, 0x7b, 0x30, 0x98, 0xdc, 0xb9, 0xc6, 0x99, 0xcb, 0x15, })] [InlineData("WXYEOQUZULMCY6ZQTDOLTRUZZMKQ====", new byte[] { 0xb5, 0xf0, 0x47, 0x42, 0x99, 0xa2, 0xd8, 0x2c, 0x7b, 0x30, 0x98, 0xdc, 0xb9, 0xc6, 0x99, 0xcb, 0x15, })]
[InlineData("wxyeoquzulmcy6zqtdoltruzzmkq====", new byte[] { 0xb5, 0xf0, 0x47, 0x42, 0x99, 0xa2, 0xd8, 0x2c, 0x7b, 0x30, 0x98, 0xdc, 0xb9, 0xc6, 0x99, 0xcb, 0x15, })]
[InlineData("2IO2HTALCXZWCBD2", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, })] [InlineData("2IO2HTALCXZWCBD2", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, })]
[InlineData("ZFXJMF5N", new byte[] { 0b11001001, 0b01101110, 0b10010110, 0b00010111, 0b10101101, })] [InlineData("ZFXJMF5N", new byte[] { 0b11001001, 0b01101110, 0b10010110, 0b00010111, 0b10101101, })]
[InlineData("zfxjmf5n", new byte[] { 0b11001001, 0b01101110, 0b10010110, 0b00010111, 0b10101101, })]
[InlineData("CPIKTMY=", new byte[] { 0b00010011, 0b11010000, 0b10101001, 0b10110011, })] [InlineData("CPIKTMY=", new byte[] { 0b00010011, 0b11010000, 0b10101001, 0b10110011, })]
[InlineData("EFCDEAA=", new byte[] { 0x21, 0x44, 0x32, 0x00, })]
[InlineData("EFCDEAI=", new byte[] { 0x21, 0x44, 0x32, 0x01, })]
[InlineData("EFCDEAQ=", new byte[] { 0x21, 0x44, 0x32, 0x02, })]
[InlineData("EFCDEAY=", new byte[] { 0x21, 0x44, 0x32, 0x03, })]
[InlineData("EFCDEBA=", new byte[] { 0x21, 0x44, 0x32, 0x04, })]
[InlineData("JVNJA===", new byte[] { 0b01001101, 0b01011010, 0b10010000, })] [InlineData("JVNJA===", new byte[] { 0b01001101, 0b01011010, 0b10010000, })]
[InlineData("74OQ====", new byte[] { 0b11111111, 0b00011101, })] [InlineData("74OQ====", new byte[] { 0b11111111, 0b00011101, })]
public void WhenCalledWithValidString_ShouldReturnValidByteArray(string str, byte[] expected) public void WhenCalledWithValidString_ShouldReturnValidByteArray(string str, byte[] expected)
{ {
var actualBytesArray = Base32.Decode(str); var actualBytesArray = Base32.Decode(str);
actualBytesArray.Should().Equal(expected); actualBytesArray.ShouldBe(expected);
} }
[Theory] [Theory]
@@ -44,7 +72,7 @@ public class Decode
public void WhenCalledWithValidStringThatEndsWithPaddingSign_ShouldReturnValidByteArray(string testString, byte[] expected) public void WhenCalledWithValidStringThatEndsWithPaddingSign_ShouldReturnValidByteArray(string testString, byte[] expected)
{ {
var actualBytesArray = Base32.Decode(testString); var actualBytesArray = Base32.Decode(testString);
actualBytesArray.Should().Equal(expected); actualBytesArray.ShouldBe(expected);
} }
[Theory] [Theory]
@@ -54,19 +82,46 @@ public class Decode
public void WhenCalledWithValidStringWithoutPaddingSign_ShouldReturnValidByteArray(string testString, byte[] expected) public void WhenCalledWithValidStringWithoutPaddingSign_ShouldReturnValidByteArray(string testString, byte[] expected)
{ {
var actualBytesArray = Base32.Decode(testString); var actualBytesArray = Base32.Decode(testString);
actualBytesArray.Should().Equal(expected); actualBytesArray.ShouldBe(expected);
} }
[Theory] [Theory]
[InlineData(" ")] [InlineData(" ")]
[InlineData("hg2515i3215")] [InlineData("hg2515i3215q")]
[InlineData("hg712)21")] [InlineData("hg712)21")]
[InlineData("hg712f 21")] [InlineData("hg712f 211")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1806:Do not ignore method results", Justification = "Test case")] [InlineData("AEBAGB^F")]
public void WhenCalledWithNotValidString_ShouldThrowFormatException(string testString) public void WhenCalledWithNotValidString_ShouldThrowFormatException(string testString)
{ {
Action action = () => Base32.Decode(testString); Action action = () => _ = Base32.Decode(testString);
action.Should().Throw<FormatException>(); action.ShouldThrow<FormatException>()
.WithMessage("Provided string contains invalid characters.");
}
[Theory]
[InlineData("A")]
[InlineData("AAA")]
[InlineData("AAAAAA")]
[InlineData("AEBAGBAFA")]
[InlineData("AEBAGBAFAAA")]
[InlineData("AEBAGBAFAAAAAA")]
public void WhenCalledWithInvalidStringLength_ShouldThrowFormatException(string testString)
{
Action action = () => _ = Base32.Decode(testString);
action.ShouldThrow<FormatException>()
.WithMessage("Invalid Base32 string length.");
}
[Theory]
[InlineData("ABCDEFG")]
[InlineData("AAAAAAB")]
[InlineData("EFCDF5J")]
[InlineData("EFCDF5P")]
public void WhenCalledWithInvalidLastByteEncoding_ShouldThrowFormatException(string testString)
{
Action action = () => _ = Base32.Decode(testString);
action.ShouldThrow<FormatException>()
.WithMessage("Invalid Base32 string. Inconsistent tail bits.");
} }
[Theory] [Theory]
@@ -74,6 +129,28 @@ public class Decode
[InlineData("")] [InlineData("")]
public void WhenCalledWithNullString_ShouldReturnEmptyArray(string? testString) public void WhenCalledWithNullString_ShouldReturnEmptyArray(string? testString)
{ {
Base32.Decode(testString).Should().BeEmpty(); Base32.Decode(testString).ShouldBeEmpty();
}
[Theory]
[InlineData("====")]
[InlineData("============")]
public void WhenCalledWithOnlyPadding_ShouldReturnEmptyArray(string testString)
{
Base32.Decode(testString).ShouldBeEmpty();
}
[Theory]
[InlineData("AEBAGBAF", 1)]
[InlineData("AEBAGBAF", 2)]
[InlineData("AEBAGBAF", 3)]
[InlineData("AEBAGBAFAY", 5)]
[InlineData("AEBAGBAFAYDQ", 6)]
public void SpanTooSmall_ThrowsArgumentException(string input, int outputLength)
{
byte[] output = new byte[outputLength];
Action action = () => _ = Base32.Decode(input, output);
action.ShouldThrow<ArgumentException>()
.WithMessage("Decoded input can not fit in output span. (Parameter 'output')");
} }
} }
+46 -5
View File
@@ -4,6 +4,7 @@ public class Encode
{ {
[Theory] [Theory]
[InlineData("TQ======")] [InlineData("TQ======")]
[InlineData("CE======")]
[InlineData("3X3A====")] [InlineData("3X3A====")]
[InlineData("426G6===")] [InlineData("426G6===")]
[InlineData("C3V3Y===")] [InlineData("C3V3Y===")]
@@ -30,8 +31,10 @@ public class Encode
{ {
var resultBytes = Base32.Decode(testString); var resultBytes = Base32.Decode(testString);
var resultString = Base32.Encode(resultBytes); var resultString = Base32.Encode(resultBytes);
var resultStringLowerCase = Base32.Encode(resultBytes, Base32EncodeOptions.LowerCase);
resultString.Should().Be(testString); resultString.ShouldBe(testString);
resultStringLowerCase.ShouldBe(testString.ToLowerInvariant());
} }
[Theory] [Theory]
@@ -47,7 +50,29 @@ public class Encode
public void WhenCalledWithNotEmptyByteArray_ShouldReturnValidString(string expected, byte[] testArray) public void WhenCalledWithNotEmptyByteArray_ShouldReturnValidString(string expected, byte[] testArray)
{ {
var str = Base32.Encode(testArray); var str = Base32.Encode(testArray);
str.Should().Be(expected); var strLowerCase = Base32.Encode(testArray, Base32EncodeOptions.LowerCase);
str.ShouldBe(expected);
strLowerCase.ShouldBe(expected.ToLowerInvariant());
}
[Theory]
[InlineData("FG4M3ZQM3TVWDMBUP5L7N7V3JS7KBM2E", new byte[] { 0x29, 0xb8, 0xcd, 0xe6, 0x0c, 0xdc, 0xeb, 0x61, 0xb0, 0x34, 0x7f, 0x57, 0xf6, 0xfe, 0xbb, 0x4c, 0xbe, 0xa0, 0xb3, 0x44, })]
[InlineData("WXYEOQUZULMCY6ZQTDOLTRUZZMKQ", new byte[] { 0xb5, 0xf0, 0x47, 0x42, 0x99, 0xa2, 0xd8, 0x2c, 0x7b, 0x30, 0x98, 0xdc, 0xb9, 0xc6, 0x99, 0xcb, 0x15, })]
[InlineData("2IO2HTALCXZWCBD2AAAAAAAAAAAA", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, })]
[InlineData("2IO2HTALCXZWCBD2AAAAAAAA", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, })]
[InlineData("2IO2HTALCXZWCBD2", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, })]
[InlineData("ZFXJMF5N", new byte[] { 0b11001001, 0b01101110, 0b10010110, 0b00010111, 0b10101101, })]
[InlineData("CPIKTMY", new byte[] { 0b00010011, 0b11010000, 0b10101001, 0b10110011, })]
[InlineData("JVNJA", new byte[] { 0b01001101, 0b01011010, 0b10010000, })]
[InlineData("74OQ", new byte[] { 0b11111111, 0b00011101, })]
public void WhenCalledWithNotEmptyByteArray_ShouldReturnValidStringWithNoPadding(string expected, byte[] testArray)
{
var str = Base32.Encode(testArray, Base32EncodeOptions.NoPadding);
var strLowerCase = Base32.Encode(testArray, Base32EncodeOptions.NoPadding | Base32EncodeOptions.LowerCase);
str.ShouldBe(expected);
strLowerCase.ShouldBe(expected.ToLowerInvariant());
} }
[Theory] [Theory]
@@ -56,7 +81,7 @@ public class Encode
public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[]? testArray) public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[]? testArray)
{ {
var actualBase32 = Base32.Encode(testArray); var actualBase32 = Base32.Encode(testArray);
actualBase32.Should().Be(string.Empty); actualBase32.ShouldBe(string.Empty);
} }
[Theory] [Theory]
@@ -68,7 +93,23 @@ public class Encode
var charsWritten = Base32.Encode(testArray, output); var charsWritten = Base32.Encode(testArray, output);
charsWritten.Should().Be(0); charsWritten.ShouldBe(0);
output.Should().Equal(['1', '2', '3', '4']); output.ShouldBe(['1', '2', '3', '4']);
}
[Theory]
[InlineData(new byte[] { 1, 2, 3 }, 3)]
[InlineData(new byte[] { 1, 2, 3 }, 4)]
[InlineData(new byte[] { 1, 2, 3 }, 5)]
[InlineData(new byte[] { 1, 2, 3 }, 6)]
[InlineData(new byte[] { 1, 2, 3 }, 7)]
[InlineData(new byte[] { 1, 2, 3, 4, 5, 6 }, 15)]
[InlineData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }, 23)]
public void SpanTooSmall_ThrowsArgumentException(byte[] input, int outputLength)
{
var output = new char[outputLength];
Action action = () => _ = Base32.Encode(input, output);
action.ShouldThrow<ArgumentException>()
.WithMessage("Encoded input can not fit in output span. (Parameter 'output')");
} }
} }
+9 -9
View File
@@ -19,7 +19,7 @@ public class Decode
var resultString = Base64Url.Encode(testBytes); var resultString = Base64Url.Encode(testBytes);
var resultBytes = Base64Url.Decode(resultString); var resultBytes = Base64Url.Decode(resultString);
resultBytes.Should().BeEquivalentTo(testBytes); resultBytes.ShouldBeEquivalentTo(testBytes);
} }
} }
@@ -39,7 +39,7 @@ public class Decode
var resultString = Base64Url.Encode(testLong); var resultString = Base64Url.Encode(testLong);
var resultLong = Base64Url.DecodeLong(resultString); var resultLong = Base64Url.DecodeLong(resultString);
resultLong.Should().Be(testLong); resultLong.ShouldBe(testLong);
} }
} }
@@ -57,7 +57,7 @@ public class Decode
public void WhenCalled_ShouldReturnValidLong(string testString, long expected) public void WhenCalled_ShouldReturnValidLong(string testString, long expected)
{ {
var result = Base64Url.DecodeLong(testString); var result = Base64Url.DecodeLong(testString);
result.Should().Be(expected); result.ShouldBe(expected);
} }
[Theory] [Theory]
@@ -70,7 +70,7 @@ public class Decode
{ {
var result = Base64Url.DecodeGuid(testString); var result = Base64Url.DecodeGuid(testString);
var expected = Guid.Parse(expectedStr); var expected = Guid.Parse(expectedStr);
result.Should().Be(expected); result.ShouldBe(expected);
} }
[Theory] [Theory]
@@ -85,7 +85,7 @@ public class Decode
public void WhenCalled_ShouldReturnValidBytes(string testString, byte[] expected) public void WhenCalled_ShouldReturnValidBytes(string testString, byte[] expected)
{ {
var result = Base64Url.Decode(testString); var result = Base64Url.Decode(testString);
result.Should().BeEquivalentTo(expected); result.ShouldBeEquivalentTo(expected);
} }
[Theory] [Theory]
@@ -97,7 +97,7 @@ public class Decode
public void WhenCalledWithInvalidString_ShouldThrowFormatException(string testString) public void WhenCalledWithInvalidString_ShouldThrowFormatException(string testString)
{ {
Action action = () => Base64Url.Decode(testString); Action action = () => Base64Url.Decode(testString);
action.Should().Throw<FormatException>(); action.ShouldThrow<FormatException>();
} }
[Theory] [Theory]
@@ -109,7 +109,7 @@ public class Decode
public void WhenCalledWithInvalidGuidString_ShouldThrowFormatException(string testString) public void WhenCalledWithInvalidGuidString_ShouldThrowFormatException(string testString)
{ {
Action action = () => Base64Url.DecodeGuid(testString); Action action = () => Base64Url.DecodeGuid(testString);
action.Should().Throw<FormatException>(); action.ShouldThrow<FormatException>();
} }
[Theory] [Theory]
@@ -122,7 +122,7 @@ public class Decode
public void WhenCalledWithInvalidLongString_ShouldThrowFormatException(string testString) public void WhenCalledWithInvalidLongString_ShouldThrowFormatException(string testString)
{ {
Action action = () => Base64Url.DecodeLong(testString); Action action = () => Base64Url.DecodeLong(testString);
action.Should().Throw<FormatException>(); action.ShouldThrow<FormatException>();
} }
[Theory] [Theory]
@@ -130,6 +130,6 @@ public class Decode
[InlineData("")] [InlineData("")]
public void WhenCalledWithNullString_ShouldReturnEmptyArray(string? testString) public void WhenCalledWithNullString_ShouldReturnEmptyArray(string? testString)
{ {
Base64Url.Decode(testString).Should().BeEmpty(); Base64Url.Decode(testString).ShouldBeEmpty();
} }
} }
+6 -6
View File
@@ -12,7 +12,7 @@ public class Encode
{ {
var testGuid = Guid.Parse(testGuidString); var testGuid = Guid.Parse(testGuidString);
var result = Base64Url.Encode(testGuid); var result = Base64Url.Encode(testGuid);
result.Should().Be(expected); result.ShouldBe(expected);
} }
[Theory] [Theory]
@@ -29,7 +29,7 @@ public class Encode
public void WhenCalledWithLong_ShouldReturnValidString(string expected, long testLong) public void WhenCalledWithLong_ShouldReturnValidString(string expected, long testLong)
{ {
var result = Base64Url.Encode(testLong); var result = Base64Url.Encode(testLong);
result.Should().Be(expected); result.ShouldBe(expected);
} }
[Theory] [Theory]
@@ -44,7 +44,7 @@ public class Encode
public void WhenCalled_ShouldReturnValidString(string expected, byte[] testBytes) public void WhenCalled_ShouldReturnValidString(string expected, byte[] testBytes)
{ {
var result = Base64Url.Encode(testBytes); var result = Base64Url.Encode(testBytes);
result.Should().Be(expected); result.ShouldBe(expected);
} }
[Theory] [Theory]
@@ -53,7 +53,7 @@ public class Encode
public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[]? testArray) public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[]? testArray)
{ {
var actualBase32 = Base64Url.Encode(testArray); var actualBase32 = Base64Url.Encode(testArray);
actualBase32.Should().Be(string.Empty); actualBase32.ShouldBe(string.Empty);
} }
[Theory] [Theory]
@@ -65,7 +65,7 @@ public class Encode
var charsWritten = Base64Url.Encode(testArray, output); var charsWritten = Base64Url.Encode(testArray, output);
charsWritten.Should().Be(0); charsWritten.ShouldBe(0);
output.Should().Equal(['1', '2', '3', '4']); output.ShouldBe((char[])['1', '2', '3', '4']);
} }
} }
+5 -9
View File
@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<AssemblyName>Just.Core.Tests</AssemblyName> <AssemblyName>Just.Core.Tests</AssemblyName>
<RootNamespace>Just.Core.Tests</RootNamespace> <RootNamespace>Just.Core.Tests</RootNamespace>
@@ -14,14 +15,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="coverlet.collector" Version="10.0.1">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
+1 -1
View File
@@ -1,2 +1,2 @@
global using Xunit; global using Xunit;
global using FluentAssertions; global using Shouldly;
+141
View File
@@ -0,0 +1,141 @@
namespace Just.Core.Tests.GuidV8Tests;
public class ExtractTimestamp
{
[Fact]
public void RoundTrip_ShouldReturnOriginalTimestamp()
{
var original = new DateTime(2026, 7, 10, 21, 30, 0, DateTimeKind.Utc);
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
var extracted = GuidV8.ExtractTimestamp(guid);
extracted.ShouldBe(original);
extracted.Kind.ShouldBe(DateTimeKind.Utc);
}
[Fact]
public void RoundTrip_ShouldPreserve100MicrosecondPrecision()
{
// 100-microsecond precision: 1234567 * 100µs = 123456.7ms
var original = new DateTime(2024, 3, 15, 8, 45, 30, 123, DateTimeKind.Utc)
.AddTicks(4567); // sub-millisecond ticks
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
var extracted = GuidV8.ExtractTimestamp(guid);
// 123ms + 4567 ticks = 123ms + 456.7µs
// timestamp = (ticks since epoch) / 1000 (100µs units)
// Ticks: 123ms = 1,230,000 ticks; + 4,567 ticks = 1,234,567 ticks
// timestamp = 1,234,567 / 1000 = 1234 (truncates last 3 digits)
// recovered = 1234 * 1000 = 1,234,000 ticks = 123.4ms
// So the sub-100µs part (67 ticks) is lost
extracted.ShouldBe(new DateTime(2024, 3, 15, 8, 45, 30, 123, DateTimeKind.Utc)
.AddTicks(4000)); // 123.4ms
}
[Fact]
public void RoundTrip_UnixEpoch_ShouldReturnExactEpoch()
{
var original = DateTime.UnixEpoch;
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
var extracted = GuidV8.ExtractTimestamp(guid);
extracted.ShouldBe(original);
}
[Fact]
public void RoundTrip_PreUnixEpoch_ShouldThrowArgumentException()
{
// Negative timestamps cannot be encoded correctly because the uint casts
// in NewGuid corrupt the sign. The encoding should reject them.
var preEpoch = new DateTime(1969, 7, 20, 20, 17, 40, DateTimeKind.Utc);
Action act = () => GuidV8.NewGuid(preEpoch);
act.ShouldThrow<ArgumentException>();
}
[Fact]
public void RoundTrip_FarFuture_ShouldPreserveTimestamp()
{
// Year 2200 — well within 48-bit range
var original = new DateTime(2200, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
var extracted = GuidV8.ExtractTimestamp(guid);
extracted.ShouldBe(original);
}
[Fact]
public void ExtractTimestamp_WithStrongEntropy_ShouldStillRecoverTimestamp()
{
var original = new DateTime(2025, 12, 25, 10, 0, 0, DateTimeKind.Utc);
var guid = GuidV8.NewGuid(original, RngEntropy.Strong);
var extracted = GuidV8.ExtractTimestamp(guid);
extracted.ShouldBe(original);
}
[Fact]
public void NonV8Guid_ShouldThrowArgumentException()
{
var guid = Guid.NewGuid(); // UUID v4
Action act = () => GuidV8.ExtractTimestamp(guid);
act.ShouldThrow<ArgumentException>()
.WithMessage("The provided GUID is not a UUID v8 (version=4). (Parameter 'guid')");
}
[Fact]
public void GuidEmpty_ShouldThrowArgumentException()
{
Action act = () => GuidV8.ExtractTimestamp(Guid.Empty);
act.ShouldThrow<ArgumentException>()
.WithMessage("The provided GUID is not a UUID v8 (version=0). (Parameter 'guid')");
}
[Fact]
public void MultipleRoundTrips_ShouldAllMatch()
{
var baseTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
for (int i = 0; i < 100; i++)
{
var original = baseTime.AddMinutes(i * 37 + 13); // varied intervals
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
var extracted = GuidV8.ExtractTimestamp(guid);
extracted.ShouldBe(original);
}
}
[Fact]
public void ExtractTimestamp_ThenNewGuid_ShouldProduceSortableSequence()
{
var t1 = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
var t2 = t1.AddSeconds(1);
var t3 = t2.AddSeconds(1);
var g1 = GuidV8.NewGuid(t1, RngEntropy.Weak);
var g2 = GuidV8.NewGuid(t2, RngEntropy.Weak);
var g3 = GuidV8.NewGuid(t3, RngEntropy.Weak);
var extracted1 = GuidV8.ExtractTimestamp(g1);
var extracted2 = GuidV8.ExtractTimestamp(g2);
var extracted3 = GuidV8.ExtractTimestamp(g3);
extracted1.ShouldBe(t1);
extracted2.ShouldBe(t2);
extracted3.ShouldBe(t3);
// Verify they sort in order
new[] { g3, g1, g2 }.Order().ShouldBe([g1, g2, g3]);
new[] { extracted3, extracted1, extracted2 }.Order().ShouldBe([t1, t2, t3]);
}
}
+25 -14
View File
@@ -14,8 +14,19 @@ public class NewGuid
{ {
var timestamp = referenceTime.AddSeconds(rng.Next()); var timestamp = referenceTime.AddSeconds(rng.Next());
var result = GuidV8.NewGuid(timestamp, entropy); var result = GuidV8.NewGuid(timestamp, entropy);
result.Version.Should().Be(8);
(result.Variant & 0b1100).Should().Be(0b1000); #if NET9_0_OR_GREATER
result.Version.ShouldBe(8);
(result.Variant & 0b1100).ShouldBe(0b1000);
#else
var bytes = result.ToByteArray();
// Check version (bits 4-7 of the 7th byte)
var version = (bytes[7] >> 4) & 0x0F;
version.ShouldBe(8); // UUID version 8
// Check variant (bits 6-7 of the 8th byte)
var variant = bytes[8] >> 6;
variant.ShouldBe(0b10); // Standard UUID variant
#endif
} }
} }
@@ -86,11 +97,11 @@ public class NewGuid
var sut = expected.Values.ToArray(); var sut = expected.Values.ToArray();
rng.Shuffle(sut); rng.Shuffle(sut);
sut.Order().Should().Equal(expected.Select(x => x.Value)); sut.Order().ShouldBe(expected.Select(x => x.Value));
sut.OrderBy(x => x.ToString()).Should().Equal(expected.Select(x => x.Value)); sut.OrderBy(x => x.ToString()).ShouldBe(expected.Select(x => x.Value));
sut.OrderDescending().Should().Equal(expected.Reverse().Select(x => x.Value)); sut.OrderDescending().ShouldBe(expected.Reverse().Select(x => x.Value));
sut.OrderByDescending(x => x.ToString()).Should().Equal(expected.Reverse().Select(x => x.Value)); sut.OrderByDescending(x => x.ToString()).ShouldBe(expected.Reverse().Select(x => x.Value));
} }
[Theory] [Theory]
@@ -160,11 +171,11 @@ public class NewGuid
var sut = expected.Values.ToArray(); var sut = expected.Values.ToArray();
rng.Shuffle(sut); rng.Shuffle(sut);
sut.Order().Should().Equal(expected.Select(x => x.Value)); sut.Order().ShouldBe(expected.Select(x => x.Value));
sut.OrderBy(x => x.ToString()).Should().Equal(expected.Select(x => x.Value)); sut.OrderBy(x => x.ToString()).ShouldBe(expected.Select(x => x.Value));
sut.OrderDescending().Should().Equal(expected.Reverse().Select(x => x.Value)); sut.OrderDescending().ShouldBe(expected.Reverse().Select(x => x.Value));
sut.OrderByDescending(x => x.ToString()).Should().Equal(expected.Reverse().Select(x => x.Value)); sut.OrderByDescending(x => x.ToString()).ShouldBe(expected.Reverse().Select(x => x.Value));
} }
[Theory] [Theory]
@@ -234,10 +245,10 @@ public class NewGuid
var sut = expected.Values.ToArray(); var sut = expected.Values.ToArray();
rng.Shuffle(sut); rng.Shuffle(sut);
sut.Order().Should().Equal(expected.Select(x => x.Value)); sut.Order().ShouldBe(expected.Select(x => x.Value));
sut.OrderBy(x => x.ToString()).Should().Equal(expected.Select(x => x.Value)); sut.OrderBy(x => x.ToString()).ShouldBe(expected.Select(x => x.Value));
sut.OrderDescending().Should().Equal(expected.Reverse().Select(x => x.Value)); sut.OrderDescending().ShouldBe(expected.Reverse().Select(x => x.Value));
sut.OrderByDescending(x => x.ToString()).Should().Equal(expected.Reverse().Select(x => x.Value)); sut.OrderByDescending(x => x.ToString()).ShouldBe(expected.Reverse().Select(x => x.Value));
} }
} }
+20 -16
View File
@@ -1,3 +1,4 @@
namespace Just.Core.Tests.SeqIdTests; namespace Just.Core.Tests.SeqIdTests;
public class NextId public class NextId
@@ -25,9 +26,9 @@ public class NextId
long sequencePart = (id >> SeqShift) & SeqMask; long sequencePart = (id >> SeqShift) & SeqMask;
long randomPart = id & RandMask; long randomPart = id & RandMask;
timestampPart.Should().Be(500); timestampPart.ShouldBe(500);
sequencePart.Should().Be(0); sequencePart.ShouldBe(0);
randomPart.Should().BeInRange(0, RandMask); randomPart.ShouldBeInRange(0, RandMask);
} }
[Fact] [Fact]
@@ -44,7 +45,7 @@ public class NextId
long sequence1 = (id1 >> SeqShift) & SeqMask; long sequence1 = (id1 >> SeqShift) & SeqMask;
long sequence2 = (id2 >> SeqShift) & SeqMask; long sequence2 = (id2 >> SeqShift) & SeqMask;
sequence2.Should().Be(sequence1 + 1); sequence2.ShouldBe(sequence1 + 1);
} }
[Fact] [Fact]
@@ -61,7 +62,7 @@ public class NextId
// Assert // Assert
long sequence = (id >> SeqShift) & SeqMask; long sequence = (id >> SeqShift) & SeqMask;
sequence.Should().Be(0); sequence.ShouldBe(0);
} }
[Fact] [Fact]
@@ -74,7 +75,7 @@ public class NextId
// Act & Assert // Act & Assert
_seqId.Next(time1); // First call sets last timestamp _seqId.Next(time1); // First call sets last timestamp
Action act = () => _seqId.Next(time2); Action act = () => _seqId.Next(time2);
act.Should().Throw<InvalidOperationException>() act.ShouldThrow<InvalidOperationException>()
.WithMessage("Refused to create new SeqId. Last timestamp is in the future."); .WithMessage("Refused to create new SeqId. Last timestamp is in the future.");
} }
@@ -85,12 +86,12 @@ public class NextId
var time = TestEpoch.AddMilliseconds(200); var time = TestEpoch.AddMilliseconds(200);
// Act & Assert // Act & Assert
for (int i = 0; i < 255; i++) for (int i = 0; i < 256; i++)
{ {
_seqId.Next(time); // Exhauste sequence _seqId.Next(time); // Use all 256 sequence values (0-255)
} }
Action act = () => _seqId.Next(time); Action act = () => _seqId.Next(time); // 257th call should throw
act.Should().Throw<IndexOutOfRangeException>() act.ShouldThrow<IndexOutOfRangeException>()
.WithMessage("Refused to create new SeqId. Sequence exhausted."); .WithMessage("Refused to create new SeqId. Sequence exhausted.");
} }
@@ -105,7 +106,7 @@ public class NextId
// Assert // Assert
long randomPart = id & RandMask; long randomPart = id & RandMask;
randomPart.Should().BeInRange(0, RandMask); randomPart.ShouldBeInRange(0, RandMask);
} }
[Fact] [Fact]
@@ -119,22 +120,25 @@ public class NextId
// Assert // Assert
long randomPart = id & RandMask; long randomPart = id & RandMask;
randomPart.Should().BeInRange(0, RandMask); randomPart.ShouldBeInRange(0, RandMask);
} }
[Fact] [Fact]
public void DefaultInstance_NextId_ShouldUseDefaultEpoch() public void DefaultInstance_NextId_ShouldUseDefaultEpoch()
{ {
// Arrange // Arrange
var now = DateTime.UtcNow; var now = new DateTime(2026, 6, 3, 21, 11, 1, DateTimeKind.Utc);
var defaultEpoch = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); SeqId.Default.UnsafeReplaceDefaultTimeFactory(() => now);
long expectedTimestamp = (long)(now - defaultEpoch).TotalMilliseconds;
// Act // Act
long id = SeqId.NextId(); long id = SeqId.NextId();
// Assert // Assert
long expectedTimestamp = GetExpectedTimestamp(now);
long timestampPart = (id >> TimestampShift) & TimestampMask; long timestampPart = (id >> TimestampShift) & TimestampMask;
timestampPart.Should().BeCloseTo(expectedTimestamp & TimestampMask, 1); // Mask handles overflow timestampPart.ShouldBeInRange(expectedTimestamp, expectedTimestamp + 1);
static long GetExpectedTimestamp(DateTime now) => ((long)(now - SeqId.DefaultEpoch).TotalMilliseconds) & TimestampMask; // Mask handles overflow
} }
} }
+11
View File
@@ -0,0 +1,11 @@
namespace Just.Core.Tests;
public static class ShouldlyExtensions
{
public static TException WithMessage<TException>(this TException exception, string expectedMessage, string? customMessage = null)
where TException : Exception
{
exception.Message.ShouldBe(expectedMessage, customMessage: customMessage);
return exception;
}
}
@@ -23,7 +23,7 @@ public class Populate
stream.Populate(buffer, offset, length); stream.Populate(buffer, offset, length);
buffer.Skip(offset).Take(length).Should().Equal(streamContent.Take(length)); buffer.Skip(offset).Take(length).ShouldBe(streamContent.Take(length));
} }
[Theory] [Theory]
@@ -38,7 +38,7 @@ public class Populate
stream.Populate(buffer); stream.Populate(buffer);
buffer.Should().Equal(streamContent.Take(bufferSize)); buffer.ShouldBe(streamContent.Take(bufferSize));
} }
[Theory] [Theory]
@@ -53,6 +53,6 @@ public class Populate
Action action = () => stream.Populate(buffer); Action action = () => stream.Populate(buffer);
action.Should().Throw<EndOfStreamException>(); action.ShouldThrow<EndOfStreamException>();
} }
} }
@@ -15,7 +15,7 @@ public class PopulateAsync
Func<Task> action = async () => await stream.PopulateAsync(buffer, cts.Token); Func<Task> action = async () => await stream.PopulateAsync(buffer, cts.Token);
cts.Cancel(); cts.Cancel();
await action.Should().ThrowAsync<OperationCanceledException>(); await action.ShouldThrowAsync<OperationCanceledException>();
} }
[Theory] [Theory]
@@ -31,13 +31,14 @@ public class PopulateAsync
[InlineData(5, 5)] [InlineData(5, 5)]
public async Task WhenCalled_ShouldPopulateSpecifiedRange(int offset, int length) public async Task WhenCalled_ShouldPopulateSpecifiedRange(int offset, int length)
{ {
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
byte[] streamContent = [0x01, 0x02, 0x03, 0x04, 0x05,]; byte[] streamContent = [0x01, 0x02, 0x03, 0x04, 0x05,];
using var stream = new MemoryStream(streamContent); using var stream = new MemoryStream(streamContent);
var buffer = new byte[10]; var buffer = new byte[10];
await stream.PopulateAsync(buffer, offset, length); await stream.PopulateAsync(buffer, offset, length, cts.Token);
buffer.Skip(offset).Take(length).Should().Equal(streamContent.Take(length)); buffer.Skip(offset).Take(length).ShouldBe(streamContent.Take(length));
} }
[Theory] [Theory]
@@ -47,12 +48,13 @@ public class PopulateAsync
[InlineData(new byte[]{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, }, 5)] [InlineData(new byte[]{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, }, 5)]
public async Task WhenStreamContainsSameOrGreaterAmmountOfItems_ShouldPopulateBuffer(byte[] streamContent, int bufferSize) public async Task WhenStreamContainsSameOrGreaterAmmountOfItems_ShouldPopulateBuffer(byte[] streamContent, int bufferSize)
{ {
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
using var stream = new MemoryStream(streamContent); using var stream = new MemoryStream(streamContent);
var buffer = new byte[bufferSize]; var buffer = new byte[bufferSize];
await stream.PopulateAsync(buffer); await stream.PopulateAsync(buffer, cts.Token);
buffer.Should().Equal(streamContent.Take(bufferSize)); buffer.ShouldBe(streamContent.Take(bufferSize));
} }
[Theory] [Theory]
@@ -67,6 +69,6 @@ public class PopulateAsync
Func<Task> action = async () => await stream.PopulateAsync(buffer); Func<Task> action = async () => await stream.PopulateAsync(buffer);
await action.Should().ThrowAsync<EndOfStreamException>(); await action.ShouldThrowAsync<EndOfStreamException>();
} }
} }
+110 -27
View File
@@ -1,28 +1,59 @@
namespace Just.Core; namespace Just.Core;
/// <summary>
/// Encoding options for <see cref="Base32.Encode(ReadOnlySpan{byte}, Base32EncodeOptions)"/>.
/// </summary>
[Flags]
public enum Base32EncodeOptions
{
/// <summary>Standard RFC 4648 encoding (uppercase with padding).</summary>
None = 0x00,
/// <summary>Use lowercase alphabet.</summary>
LowerCase = 0x01,
/// <summary>Omit padding characters.</summary>
NoPadding = 0x02,
/// <summary>Lowercase alphabet without padding (combines <see cref="LowerCase"/> | <see cref="NoPadding"/>).</summary>
LowerCaseNoPadding = 0x03,
}
/// <summary>
/// RFC 4648 Base32 encoder/decoder with span-based APIs.
/// Uses stack allocation for inputs up to <see cref="MaxBytesStack"/> bytes; heap allocation otherwise.
/// </summary>
public static class Base32 public static class Base32
{ {
/// <summary>RFC 4648 Base32 alphabet (uppercase A-Z, 2-7).</summary>
public const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; public const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/// <summary>Lowercase variant of the RFC 4648 alphabet.</summary>
public const string AlphabetLower = "abcdefghijklmnopqrstuvwxyz234567";
/// <summary>Padding character (=).</summary>
public const char Padding = '='; public const char Padding = '=';
/// <summary>Maximum input byte count that uses stack allocation instead of heap.</summary>
public const int MaxBytesStack = 250; public const int MaxBytesStack = 250;
/// <summary>
/// Encodes a byte span into a Base32 string.
/// </summary>
/// <param name="input">The bytes to encode.</param>
/// <param name="options">Encoding options (casing, padding). Defaults to standard RFC 4648.</param>
/// <returns>The Base32-encoded string.</returns>
[Pure] [Pure]
public static string Encode(ReadOnlySpan<byte> input) public static string Encode(ReadOnlySpan<byte> input, Base32EncodeOptions options = Base32EncodeOptions.None)
{ {
if (input.IsEmpty) return string.Empty; if (input.IsEmpty) return string.Empty;
int outLength = 8 * ((input.Length + 4) / 5); int outLength = 8 * ((input.Length + 4) / 5);
Span<char> output = input.Length <= MaxBytesStack Span<char> output = input.Length > MaxBytesStack
? stackalloc char[outLength] ? new char[outLength]
: new char[outLength]; : stackalloc char[outLength];
var size = Encode(input, output); var size = Encode(input, output, options);
return new string(output[..size]); return new string(output[..size]);
} }
[Pure] [Pure]
public static int Encode(ReadOnlySpan<byte> input, Span<char> output) public static int Encode(ReadOnlySpan<byte> input, Span<char> output, Base32EncodeOptions options = Base32EncodeOptions.None)
{ {
if (input.IsEmpty) return 0; if (input.IsEmpty) return 0;
@@ -34,25 +65,63 @@ public static class Base32
output = output[..outputLength]; output = output[..outputLength];
int i = 0; ReadOnlySpan<char> alphabet = (options & Base32EncodeOptions.LowerCase) == Base32EncodeOptions.LowerCase
ReadOnlySpan<char> alphabet = Alphabet; ? AlphabetLower
: Alphabet;
Span<byte> alphabetKeys = stackalloc byte[8]; Span<byte> alphabetKeys = stackalloc byte[8];
int i = 0;
for (int offset = 0; offset < input.Length;) for (int offset = 0; offset < input.Length;)
{ {
alphabetKeys.Clear(); alphabetKeys.Clear();
int numCharsToOutput = GetNextGroup(input, ref offset, alphabetKeys); int numCharsToOutput = GetNextGroup(input, ref offset, alphabetKeys);
output[i++] = (numCharsToOutput > 0) ? alphabet[alphabetKeys[0]] : Padding; output[i++] = alphabet[alphabetKeys[0]];
output[i++] = (numCharsToOutput > 1) ? alphabet[alphabetKeys[1]] : Padding; output[i++] = alphabet[alphabetKeys[1]];
output[i++] = (numCharsToOutput > 2) ? alphabet[alphabetKeys[2]] : Padding;
output[i++] = (numCharsToOutput > 3) ? alphabet[alphabetKeys[3]] : Padding; if (numCharsToOutput < 3)
output[i++] = (numCharsToOutput > 4) ? alphabet[alphabetKeys[4]] : Padding; {
output[i++] = (numCharsToOutput > 5) ? alphabet[alphabetKeys[5]] : Padding; i = FillWithPadding(output, i, numCharsToOutput, options);
output[i++] = (numCharsToOutput > 6) ? alphabet[alphabetKeys[6]] : Padding; break;
output[i++] = (numCharsToOutput > 7) ? alphabet[alphabetKeys[7]] : Padding; }
output[i++] = alphabet[alphabetKeys[2]];
output[i++] = alphabet[alphabetKeys[3]];
if (numCharsToOutput < 5)
{
i = FillWithPadding(output, i, numCharsToOutput, options);
break;
}
output[i++] = alphabet[alphabetKeys[4]];
if (numCharsToOutput < 6)
{
i = FillWithPadding(output, i, numCharsToOutput, options);
break;
}
output[i++] = alphabet[alphabetKeys[5]];
output[i++] = alphabet[alphabetKeys[6]];
if (numCharsToOutput < 8)
{
i = FillWithPadding(output, i, numCharsToOutput, options);
break;
}
output[i++] = alphabet[alphabetKeys[7]];
} }
return i; return i;
static int FillWithPadding(Span<char> output, int i, int numCharsToOutput, Base32EncodeOptions options)
{
if ((options & Base32EncodeOptions.NoPadding) == Base32EncodeOptions.NoPadding)
{
return i;
}
var pad = 8 - numCharsToOutput;
output[i..(i+pad)].Fill(Padding);
return i + pad;
}
} }
[Pure] [Pure]
@@ -61,10 +130,10 @@ public static class Base32
input = input.TrimEnd(Padding); input = input.TrimEnd(Padding);
if (input.IsEmpty) return []; if (input.IsEmpty) return [];
var outputLength = 5 * ((input.Length + 7) / 8); var outputLength = 5 * input.Length / 8;
Span<byte> output = outputLength <= MaxBytesStack Span<byte> output = outputLength > MaxBytesStack
? stackalloc byte[outputLength] ? new byte[outputLength]
: new byte[outputLength]; : stackalloc byte[outputLength];
var size = Decode(input, output); var size = Decode(input, output);
@@ -76,7 +145,13 @@ public static class Base32
{ {
input = input.TrimEnd(Padding); input = input.TrimEnd(Padding);
var outputLength = 5 * ((input.Length + 7) / 8); var rem = input.Length % 8;
if (rem == 1 || rem == 3 || rem == 6)
{
throw new FormatException("Invalid Base32 string length.");
}
var outputLength = 5 * input.Length / 8;
if (output.Length < outputLength) if (output.Length < outputLength)
{ {
throw new ArgumentException("Decoded input can not fit in output span.", nameof(output)); throw new ArgumentException("Decoded input can not fit in output span.", nameof(output));
@@ -85,9 +160,9 @@ public static class Base32
output = output[..outputLength]; output = output[..outputLength];
output.Clear(); output.Clear();
Span<char> inputspan = outputLength <= MaxBytesStack Span<char> inputspan = outputLength > MaxBytesStack
? stackalloc char[input.Length] ? new char[input.Length]
: new char[input.Length]; : stackalloc char[input.Length];
input.ToUpperInvariant(inputspan); input.ToUpperInvariant(inputspan);
int bitIndex = 0; int bitIndex = 0;
@@ -127,10 +202,17 @@ public static class Base32
inputIndex++; inputIndex++;
bitIndex = 0; bitIndex = 0;
} }
else if (inputIndex == input.Length -1) break; else if (inputIndex == input.Length -1)
{
if ((byteIndex & ~(0x1f << (bitPos - bits))) > 0)
{
throw new FormatException("Invalid Base32 string. Inconsistent tail bits.");
}
break;
}
} }
return outputIndex + (outputBits + 7) / 8; return outputIndex;
} }
// returns the number of bytes that were output // returns the number of bytes that were output
@@ -145,7 +227,8 @@ public static class Base32
4 => 7, 4 => 7,
_ => 8, _ => 8,
}; };
uint b1 = (offset < input.Length) ? input[offset++] : 0U;
uint b1 = input[offset++];
uint b2 = (offset < input.Length) ? input[offset++] : 0U; uint b2 = (offset < input.Length) ? input[offset++] : 0U;
uint b3 = (offset < input.Length) ? input[offset++] : 0U; uint b3 = (offset < input.Length) ? input[offset++] : 0U;
uint b4 = (offset < input.Length) ? input[offset++] : 0U; uint b4 = (offset < input.Length) ? input[offset++] : 0U;
+12
View File
@@ -2,10 +2,15 @@ using System.Runtime.InteropServices;
namespace Just.Core; namespace Just.Core;
/// <summary>
/// URL-safe Base64 encoder/decoder for bytes, <see cref="long"/>, and <see cref="Guid"/>.
/// Uses the standard Base64Url character set (- and _ instead of + and /), padding stripped by default.
/// </summary>
public static class Base64Url public static class Base64Url
{ {
private const char Padding = '='; private const char Padding = '=';
/// <summary>Decodes an 11-character Base64Url string into a <see cref="long"/>.</summary>
[Pure] public static long DecodeLong(ReadOnlySpan<char> value) [Pure] public static long DecodeLong(ReadOnlySpan<char> value)
{ {
ArgumentOutOfRangeException.ThrowIfNotEqual(value.Length, 11); ArgumentOutOfRangeException.ThrowIfNotEqual(value.Length, 11);
@@ -24,6 +29,7 @@ public static class Base64Url
return MemoryMarshal.Read<long>(longBytes); return MemoryMarshal.Read<long>(longBytes);
} }
/// <summary>Decodes a 22-character Base64Url string into a <see cref="Guid"/>.</summary>
[Pure] public static Guid DecodeGuid(ReadOnlySpan<char> value) [Pure] public static Guid DecodeGuid(ReadOnlySpan<char> value)
{ {
ArgumentOutOfRangeException.ThrowIfNotEqual(value.Length, 22); ArgumentOutOfRangeException.ThrowIfNotEqual(value.Length, 22);
@@ -42,6 +48,7 @@ public static class Base64Url
return new Guid(guidBytes); return new Guid(guidBytes);
} }
/// <summary>Decodes a Base64Url string into a byte array.</summary>
[Pure] public static byte[] Decode(ReadOnlySpan<char> input) [Pure] public static byte[] Decode(ReadOnlySpan<char> input)
{ {
if (input.IsEmpty) return []; if (input.IsEmpty) return [];
@@ -53,6 +60,7 @@ public static class Base64Url
return output[..size].ToArray(); return output[..size].ToArray();
} }
/// <summary>Decodes a Base64Url string into a pre-allocated byte span. Returns the number of bytes written.</summary>
[Pure] public static int Decode(ReadOnlySpan<char> value, Span<byte> output) [Pure] public static int Decode(ReadOnlySpan<char> value, Span<byte> output)
{ {
var padding = (4 - (value.Length & 3)) & 3; var padding = (4 - (value.Length & 3)) & 3;
@@ -72,6 +80,7 @@ public static class Base64Url
return outputBytes; return outputBytes;
} }
/// <summary>Encodes a <see cref="long"/> into an 11-character Base64Url string.</summary>
[Pure] public static string Encode(in long id) [Pure] public static string Encode(in long id)
{ {
Span<byte> longBytes = stackalloc byte[8]; Span<byte> longBytes = stackalloc byte[8];
@@ -84,6 +93,7 @@ public static class Base64Url
return new string(chars[..^1]); return new string(chars[..^1]);
} }
/// <summary>Encodes a <see cref="Guid"/> into a 22-character Base64Url string.</summary>
[Pure] public static string Encode(in Guid id) [Pure] public static string Encode(in Guid id)
{ {
Span<byte> guidBytes = stackalloc byte[16]; Span<byte> guidBytes = stackalloc byte[16];
@@ -96,6 +106,7 @@ public static class Base64Url
return new string(chars[..^2]); return new string(chars[..^2]);
} }
/// <summary>Encodes a byte span into a Base64Url string.</summary>
[Pure] public static string Encode(ReadOnlySpan<byte> input) [Pure] public static string Encode(ReadOnlySpan<byte> input)
{ {
if (input.IsEmpty) return string.Empty; if (input.IsEmpty) return string.Empty;
@@ -107,6 +118,7 @@ public static class Base64Url
return new string(output[..strlen]); return new string(output[..strlen]);
} }
/// <summary>Encodes a byte span into a pre-allocated char span. Returns the number of characters written.</summary>
[Pure] public static int Encode(ReadOnlySpan<byte> input, Span<char> output) [Pure] public static int Encode(ReadOnlySpan<byte> input, Span<char> output)
{ {
if (input.IsEmpty) return 0; if (input.IsEmpty) return 0;
+9 -5
View File
@@ -1,9 +1,13 @@
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Just.Core.Extensions;
namespace Just.Core.Collections; namespace Just.Core.Collections;
/// <summary>
/// A 2D grid with lazy min/max caching, cloning, and binary stream serialization.
/// Requires <typeparamref name="T"/> to support comparison and equality operators.
/// </summary>
/// <typeparam name="T">The element type; must implement <c>IComparisonOperators&lt;T, T, bool&gt;</c> and <c>IEqualityOperators&lt;T, T, bool&gt;</c>.</typeparam>
public class DataMap<T> : Map<T>, IDataMap<T>, ICloneable public class DataMap<T> : Map<T>, IDataMap<T>, ICloneable
where T : IComparisonOperators<T, T, bool>, IEqualityOperators<T, T, bool> where T : IComparisonOperators<T, T, bool>, IEqualityOperators<T, T, bool>
{ {
@@ -122,14 +126,14 @@ public static class DataMap
where T : unmanaged, IComparisonOperators<T, T, bool>, IEqualityOperators<T, T, bool> where T : unmanaged, IComparisonOperators<T, T, bool>, IEqualityOperators<T, T, bool>
{ {
byte[] headBytes = new byte[HeaderSize]; byte[] headBytes = new byte[HeaderSize];
await stream.PopulateAsync(headBytes, cancellationToken); await stream.ReadExactlyAsync(headBytes, cancellationToken);
var head = MemoryMarshal.Read<Header>(headBytes); var head = MemoryMarshal.Read<Header>(headBytes);
var bodySize = DataMap<T>.ElementSize * head.Width * head.Height; var bodySize = DataMap<T>.ElementSize * head.Width * head.Height;
if (bodySize != head.BodySize) throw new InvalidOperationException("Can not read DataMap. Element size mismatch."); if (bodySize != head.BodySize) throw new InvalidOperationException("Can not read DataMap. Element size mismatch.");
byte[] bodyBytes = new byte[bodySize]; byte[] bodyBytes = new byte[bodySize];
await stream.PopulateAsync(bodyBytes, cancellationToken); await stream.ReadExactlyAsync(bodyBytes, cancellationToken);
T[] body = MemoryMarshal.Cast<byte, T>(bodyBytes).ToArray(); T[] body = MemoryMarshal.Cast<byte, T>(bodyBytes).ToArray();
return new DataMap<T>((int)head.Width, (int)head.Height, body); return new DataMap<T>((int)head.Width, (int)head.Height, body);
@@ -157,7 +161,7 @@ public static class DataMap
{ {
Header head = default; Header head = default;
var headSpan = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref head, 1)); var headSpan = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref head, 1));
stream.Populate(headSpan); stream.ReadExactly(headSpan);
var bodySize = DataMap<T>.ElementSize * head.Width * head.Height; var bodySize = DataMap<T>.ElementSize * head.Width * head.Height;
if (bodySize != head.BodySize) throw new InvalidOperationException("Can not read DataMap. Element size mismatch."); if (bodySize != head.BodySize) throw new InvalidOperationException("Can not read DataMap. Element size mismatch.");
@@ -165,7 +169,7 @@ public static class DataMap
T[] body = new T[bodySize]; T[] body = new T[bodySize];
var bodySpan = MemoryMarshal.AsBytes(body.AsSpan()); var bodySpan = MemoryMarshal.AsBytes(body.AsSpan());
stream.Populate(bodySpan); stream.ReadExactly(bodySpan);
return new DataMap<T>((int)head.Width, (int)head.Height, body); return new DataMap<T>((int)head.Width, (int)head.Height, body);
} }
+2
View File
@@ -2,6 +2,8 @@ using System.Numerics;
namespace Just.Core.Collections; namespace Just.Core.Collections;
/// <summary>Read-only interface for a 2D grid with min/max tracking and cloning.</summary>
/// <typeparam name="T">The element type; must support comparison and equality operators.</typeparam>
public interface IDataMap<T> : IMap<T>, ICloneable public interface IDataMap<T> : IMap<T>, ICloneable
where T : IComparisonOperators<T, T, bool>, IEqualityOperators<T, T, bool> where T : IComparisonOperators<T, T, bool>, IEqualityOperators<T, T, bool>
{ {
+2
View File
@@ -1,5 +1,7 @@
namespace Just.Core.Collections; namespace Just.Core.Collections;
/// <summary>Read-only interface for a 2D grid with coordinate-based access and enumeration.</summary>
/// <typeparam name="T">The type of elements in the map.</typeparam>
public interface IMap<T> : IReadOnlyCollection<MapPoint<T>> public interface IMap<T> : IReadOnlyCollection<MapPoint<T>>
{ {
int Width { get; } int Width { get; }
+117 -1
View File
@@ -3,6 +3,22 @@ using System.Collections.Immutable;
namespace Just.Core.Collections; namespace Just.Core.Collections;
/// <summary>
/// Represents an immutable, ordered sequence of items with valueequality semantics.
/// </summary>
/// <typeparam name="T">The type of elements in the sequence.</typeparam>
/// <remarks>
/// <para>
/// This class is a thin wrapper around <see cref="ImmutableList{T}"/> that implements
/// <see cref="IReadOnlyList{T}"/>, <see cref="IEquatable{T}"/>, and valuebased equality.
/// All modifications return new <see cref="ImmutableSequence{T}"/> instances, leaving the
/// original unchanged.
/// </para>
/// <para>
/// Subclasses may override <see cref="ConstructNew"/> to ensure mutation methods return
/// the correct derived type.
/// </para>
/// </remarks>
public class ImmutableSequence<T> : public class ImmutableSequence<T> :
IEnumerable<T>, IEnumerable<T>,
IReadOnlyList<T>, IReadOnlyList<T>,
@@ -11,17 +27,61 @@ public class ImmutableSequence<T> :
private static readonly Func<T?, T?, bool> CompareItem = EqualityComparer<T>.Default.Equals; private static readonly Func<T?, T?, bool> CompareItem = EqualityComparer<T>.Default.Equals;
private readonly ImmutableList<T> _values; private readonly ImmutableList<T> _values;
/// <summary>
/// Initializes a new empty instance of the <see cref="ImmutableSequence{T}"/> class.
/// </summary>
public ImmutableSequence() => _values = []; public ImmutableSequence() => _values = [];
/// <summary>
/// Initializes a new instance of the <see cref="ImmutableSequence{T}"/> class that
/// wraps the specified <see cref="ImmutableList{T}"/>.
/// </summary>
/// <param name="values">The immutable list to wrap.</param>
public ImmutableSequence(ImmutableList<T> values) => _values = values; public ImmutableSequence(ImmutableList<T> values) => _values = values;
/// <summary>
/// Initializes a new instance of the <see cref="ImmutableSequence{T}"/> class with
/// the elements from the provided enumerable sequence.
/// </summary>
/// <param name="values">The items to include in the sequence.</param>
public ImmutableSequence(IEnumerable<T> values) => _values = [..values]; public ImmutableSequence(IEnumerable<T> values) => _values = [..values];
/// <summary>
/// Initializes a new instance of the <see cref="ImmutableSequence{T}"/> class with
/// the elements from the provided readonly span.
/// </summary>
/// <param name="values">The items to include in the sequence.</param>
public ImmutableSequence(ReadOnlySpan<T> values) : this(ImmutableList.Create(values)) public ImmutableSequence(ReadOnlySpan<T> values) : this(ImmutableList.Create(values))
{ {
} }
/// <summary>
/// Gets a value indicating whether the sequence contains any elements.
/// </summary>
public bool IsEmpty => _values.IsEmpty; public bool IsEmpty => _values.IsEmpty;
/// <summary>
/// Gets the number of elements in the sequence.
/// </summary>
public int Count => _values.Count; public int Count => _values.Count;
/// <summary>
/// Gets the element at the specified zerobased index.
/// </summary>
/// <param name="index">The zerobased index of the element to get.</param>
/// <returns>The element at the specified index.</returns>
public T this[int index] => _values[index]; public T this[int index] => _values[index];
/// <summary>
/// Gets the element at the specified position from the start or end of the sequence.
/// </summary>
/// <param name="index">An <see cref="Index"/> value (e.g., <c>^1</c> for the last element).</param>
/// <returns>The element at the specified position.</returns>
public T this[Index index] => _values[index]; public T this[Index index] => _values[index];
/// <summary>
/// Gets a new <see cref="ImmutableSequence{T}"/> containing the elements in the specified range.
/// </summary>
/// <param name="range">The range of elements to include.</param>
/// <returns>A new sequence representing the slice.</returns>
public ImmutableSequence<T> this[Range range] public ImmutableSequence<T> this[Range range]
{ {
get get
@@ -31,25 +91,60 @@ public class ImmutableSequence<T> :
} }
} }
/// <summary>
/// Creates a new <see cref="ImmutableSequence{T}"/> from the provided immutable list.
/// Subclasses can override this to return instances of a more specific type.
/// </summary>
/// <param name="values">The immutable list that will become the internal storage.</param>
/// <returns>A new sequence containing the given items.</returns>
protected virtual ImmutableSequence<T> ConstructNew(ImmutableList<T> values) => [..values]; protected virtual ImmutableSequence<T> ConstructNew(ImmutableList<T> values) => [..values];
/// <summary>
/// Returns a new sequence with the specified value appended to the end.
/// </summary>
/// <param name="value">The value to add.</param>
/// <returns>A new sequence containing the original items followed by <paramref name="value"/>.</returns>
public ImmutableSequence<T> Add(T value) => ConstructNew(_values.Add(value)); public ImmutableSequence<T> Add(T value) => ConstructNew(_values.Add(value));
/// <summary>
/// Returns a new sequence with the specified value inserted at the beginning.
/// </summary>
/// <param name="value">The value to add.</param>
/// <returns>A new sequence that starts with <paramref name="value"/> and then contains the original items.</returns>
public ImmutableSequence<T> AddFront(T value) => ConstructNew(_values.Insert(0, value)); public ImmutableSequence<T> AddFront(T value) => ConstructNew(_values.Insert(0, value));
/// <summary>
/// Returns an enumerator that iterates through the sequence.
/// </summary>
/// <returns>A <see cref="ImmutableList{T}.Enumerator"/> value type enumerator.</returns>
public ImmutableList<T>.Enumerator GetEnumerator() => _values.GetEnumerator(); public ImmutableList<T>.Enumerator GetEnumerator() => _values.GetEnumerator();
IEnumerator<T> IEnumerable<T>.GetEnumerator() => ((IEnumerable<T>)_values).GetEnumerator(); IEnumerator<T> IEnumerable<T>.GetEnumerator() => ((IEnumerable<T>)_values).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_values).GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_values).GetEnumerator();
public override string ToString() => string.Join(Environment.NewLine, _values); public override string ToString() => string.Join(Environment.NewLine, _values);
/// <summary>
/// Determines whether this sequence is equal to another <see cref="ImmutableSequence{T}"/>.
/// Equality is based on the number of elements and the elementwise equality comparison
/// using the default equality comparer for <typeparamref name="T"/>.
/// </summary>
/// <param name="other">The sequence to compare with this instance. Can be <c>null</c>.</param>
/// <returns>
/// <c>true</c> if the sequences have the same length and all elements are equal;
/// <c>false</c> otherwise.
/// </returns>
public virtual bool Equals([NotNullWhen(true)] ImmutableSequence<T>? other) public virtual bool Equals([NotNullWhen(true)] ImmutableSequence<T>? other)
{ {
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other)) if (ReferenceEquals(this, other))
{ {
return true; return true;
} }
if (_values.Count != other?._values.Count) if (_values.Count != other._values.Count)
{ {
return false; return false;
} }
@@ -65,7 +160,16 @@ public class ImmutableSequence<T> :
return true; return true;
} }
/// <summary>
/// Determines whether the specified object is equal to the current sequence.
/// </summary>
/// <param name="obj">The object to compare with the current sequence.</param>
/// <returns><c>true</c> if <paramref name="obj"/> is an <see cref="ImmutableSequence{T}"/> and equals this instance; otherwise <c>false</c>.</returns>
public override bool Equals([NotNullWhen(true)] object? obj) => Equals(obj as ImmutableSequence<T>); public override bool Equals([NotNullWhen(true)] object? obj) => Equals(obj as ImmutableSequence<T>);
/// <summary>
/// Serves as a hash function for the sequence.
/// </summary>
/// <returns>A hash code that incorporates all elements in order.</returns>
public override int GetHashCode() public override int GetHashCode()
{ {
HashCode hash = new(); HashCode hash = new();
@@ -78,6 +182,18 @@ public class ImmutableSequence<T> :
return hash.ToHashCode(); return hash.ToHashCode();
} }
/// <summary>
/// Determines whether two sequences are equal.
/// </summary>
/// <param name="left">The first sequence to compare.</param>
/// <param name="right">The second sequence to compare.</param>
/// <returns><c>true</c> if both sequences are <c>null</c> or they are considered equal; otherwise <c>false</c>.</returns>
public static bool operator ==(ImmutableSequence<T>? left, ImmutableSequence<T>? right) => left is null ? right is null : left.Equals(right); public static bool operator ==(ImmutableSequence<T>? left, ImmutableSequence<T>? right) => left is null ? right is null : left.Equals(right);
/// <summary>
/// Determines whether two sequences are not equal.
/// </summary>
/// <param name="left">The first sequence to compare.</param>
/// <param name="right">The second sequence to compare.</param>
/// <returns><c>true</c> if the sequences are not equal; otherwise <c>false</c>.</returns>
public static bool operator !=(ImmutableSequence<T>? left, ImmutableSequence<T>? right) => !(left == right); public static bool operator !=(ImmutableSequence<T>? left, ImmutableSequence<T>? right) => !(left == right);
} }
+7 -2
View File
@@ -2,6 +2,11 @@ using System.Collections;
namespace Just.Core.Collections; namespace Just.Core.Collections;
/// <summary>
/// A 2D grid container providing indexer access, enumeration as <see cref="MapPoint{T}"/> values,
/// and conversion to a 2D array. Coordinates are clamped to valid ranges.
/// </summary>
/// <typeparam name="T">The type of elements in the map.</typeparam>
public class Map<T> : IMap<T> public class Map<T> : IMap<T>
{ {
internal readonly T[] _values; internal readonly T[] _values;
@@ -68,8 +73,8 @@ public class Map<T> : IMap<T>
{ {
get get
{ {
x = Math.Clamp(x, 0, Width); x = Math.Clamp(x, 0, Width - 1);
y = Math.Clamp(y, 0, Height); y = Math.Clamp(y, 0, Height - 1);
return ref _values[(y * Width) + x]; return ref _values[(y * Width) + x];
} }
} }
+2
View File
@@ -1,5 +1,7 @@
namespace Just.Core.Collections; namespace Just.Core.Collections;
/// <summary>A value and its coordinates within a <see cref="IMap{T}"/>.</summary>
/// <typeparam name="T">The type of the map element.</typeparam>
public readonly struct MapPoint<T>(T value, int x, int y, IMap<T> map) public readonly struct MapPoint<T>(T value, int x, int y, IMap<T> map)
{ {
public readonly T Value = value; public readonly T Value = value;
+4 -2
View File
@@ -1,16 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks> <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyName>Just.Core</AssemblyName> <AssemblyName>Just.Core</AssemblyName>
<RootNamespace>Just.Core</RootNamespace> <RootNamespace>Just.Core</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<Description>Small .Net library with useful helper classes, functions and extensions.</Description> <Description>Small .Net library with useful helper classes, functions and extensions.</Description>
<PackageTags>extensions;helpers;helper-functions</PackageTags> <PackageTags>extensions;helpers;helper-functions</PackageTags>
<Authors>JustFixMe</Authors> <Authors>JustFixMe</Authors>
<Copyright>Copyright (c) 2023-2025 JustFixMe</Copyright> <Copyright>Copyright (c) 2023-2026 JustFixMe</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile> <PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/JustFixMe/Just.Core/</RepositoryUrl> <RepositoryUrl>https://github.com/JustFixMe/Just.Core/</RepositoryUrl>
@@ -3,6 +3,7 @@ namespace Just.Core.Extensions;
/// <summary> /// <summary>
/// Provides extension methods for <see cref="Stream"/> to fully populate buffers. /// Provides extension methods for <see cref="Stream"/> to fully populate buffers.
/// </summary> /// </summary>
[Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")]
public static class SystemIOStreamExtensions public static class SystemIOStreamExtensions
{ {
/// <summary> /// <summary>
@@ -16,6 +17,7 @@ public static class SystemIOStreamExtensions
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="offset"/> or <paramref name="length"/> is invalid</exception> /// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="offset"/> or <paramref name="length"/> is invalid</exception>
/// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception> /// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception>
/// <exception cref="IOException">Thrown for I/O errors during reading</exception> /// <exception cref="IOException">Thrown for I/O errors during reading</exception>
[Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")]
public static void Populate(this Stream stream, byte[] buffer, int offset, int length) public static void Populate(this Stream stream, byte[] buffer, int offset, int length)
=> stream.Populate(buffer.AsSpan(offset, length)); => stream.Populate(buffer.AsSpan(offset, length));
/// <summary> /// <summary>
@@ -26,6 +28,7 @@ public static class SystemIOStreamExtensions
/// <exception cref="ArgumentNullException">Thrown when <paramref name="stream"/> is null</exception> /// <exception cref="ArgumentNullException">Thrown when <paramref name="stream"/> is null</exception>
/// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception> /// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception>
/// <exception cref="IOException">Thrown for I/O errors during reading</exception> /// <exception cref="IOException">Thrown for I/O errors during reading</exception>
[Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")]
public static void Populate(this Stream stream, byte[] buffer) public static void Populate(this Stream stream, byte[] buffer)
=> stream.Populate(buffer.AsSpan()); => stream.Populate(buffer.AsSpan());
/// <summary> /// <summary>
@@ -36,6 +39,7 @@ public static class SystemIOStreamExtensions
/// <exception cref="ArgumentNullException">Thrown when <paramref name="stream"/> is null</exception> /// <exception cref="ArgumentNullException">Thrown when <paramref name="stream"/> is null</exception>
/// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception> /// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception>
/// <exception cref="IOException">Thrown for I/O errors during reading</exception> /// <exception cref="IOException">Thrown for I/O errors during reading</exception>
[Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")]
public static void Populate(this Stream stream, Span<byte> buffer) public static void Populate(this Stream stream, Span<byte> buffer)
{ {
ArgumentNullException.ThrowIfNull(stream); ArgumentNullException.ThrowIfNull(stream);
@@ -64,6 +68,7 @@ public static class SystemIOStreamExtensions
/// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception> /// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception>
/// <exception cref="OperationCanceledException">Thrown if canceled via cancellation token</exception> /// <exception cref="OperationCanceledException">Thrown if canceled via cancellation token</exception>
/// <exception cref="IOException">Thrown for I/O errors during reading</exception> /// <exception cref="IOException">Thrown for I/O errors during reading</exception>
[Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")]
public static ValueTask PopulateAsync(this Stream stream, byte[] buffer, CancellationToken cancellationToken = default) public static ValueTask PopulateAsync(this Stream stream, byte[] buffer, CancellationToken cancellationToken = default)
=> stream.PopulateAsync(buffer.AsMemory(), cancellationToken); => stream.PopulateAsync(buffer.AsMemory(), cancellationToken);
/// <summary> /// <summary>
@@ -80,6 +85,7 @@ public static class SystemIOStreamExtensions
/// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception> /// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception>
/// <exception cref="OperationCanceledException">Thrown if canceled via cancellation token</exception> /// <exception cref="OperationCanceledException">Thrown if canceled via cancellation token</exception>
/// <exception cref="IOException">Thrown for I/O errors during reading</exception> /// <exception cref="IOException">Thrown for I/O errors during reading</exception>
[Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")]
public static ValueTask PopulateAsync(this Stream stream, byte[] buffer, int offset, int length, CancellationToken cancellationToken = default) public static ValueTask PopulateAsync(this Stream stream, byte[] buffer, int offset, int length, CancellationToken cancellationToken = default)
=> stream.PopulateAsync(buffer.AsMemory(offset, length), cancellationToken); => stream.PopulateAsync(buffer.AsMemory(offset, length), cancellationToken);
/// <summary> /// <summary>
@@ -93,6 +99,7 @@ public static class SystemIOStreamExtensions
/// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception> /// <exception cref="EndOfStreamException">Thrown if the stream ends before filling the buffer</exception>
/// <exception cref="OperationCanceledException">Thrown if canceled via cancellation token</exception> /// <exception cref="OperationCanceledException">Thrown if canceled via cancellation token</exception>
/// <exception cref="IOException">Thrown for I/O errors during reading</exception> /// <exception cref="IOException">Thrown for I/O errors during reading</exception>
[Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")]
public static async ValueTask PopulateAsync(this Stream stream, Memory<byte> buffer, CancellationToken cancellationToken = default) public static async ValueTask PopulateAsync(this Stream stream, Memory<byte> buffer, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(stream); ArgumentNullException.ThrowIfNull(stream);
+42 -1
View File
@@ -1,12 +1,14 @@
using System.Runtime.InteropServices;
using System.Security.Cryptography; using System.Security.Cryptography;
namespace Just.Core; namespace Just.Core;
public static class GuidV8 public static class GuidV8
{ {
private const long TicksPrecision = TimeSpan.TicksPerMillisecond / 10; private const long TicksPrecision = TimeSpan.TicksPerMillisecond / 10; // 100-microsecond units
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
[ExcludeFromCodeCoverage]
public static Guid NewGuid(RngEntropy entropy = RngEntropy.Strong) => NewGuid(DateTime.UtcNow, entropy); public static Guid NewGuid(RngEntropy entropy = RngEntropy.Strong) => NewGuid(DateTime.UtcNow, entropy);
public static Guid NewGuid(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong) public static Guid NewGuid(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong)
@@ -14,6 +16,12 @@ public static class GuidV8
var epoch = dateTime.Subtract(DateTime.UnixEpoch); var epoch = dateTime.Subtract(DateTime.UnixEpoch);
var timestamp = epoch.Ticks / TicksPrecision; var timestamp = epoch.Ticks / TicksPrecision;
// Negative timestamps can't be encoded correctly due to unsigned bit operations
if (timestamp < 0)
{
throw new ArgumentException("Timestamp must be on or after UnixEpoch (1970-01-01 UTC).", nameof(dateTime));
}
uint tsHigh = (uint)((timestamp >> 16) & 0xFFFFFFFF); uint tsHigh = (uint)((timestamp >> 16) & 0xFFFFFFFF);
ushort tsLow = (ushort)(timestamp & 0x0000FFFF); ushort tsLow = (ushort)(timestamp & 0x0000FFFF);
@@ -39,4 +47,37 @@ public static class GuidV8
version, version,
bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9]); bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9]);
} }
/// <summary>
/// Extracts the timestamp from a UUID v8 generated by <see cref="NewGuid(DateTime, RngEntropy)"/>.
/// </summary>
/// <param name="guid">The UUID v8 to extract the timestamp from.</param>
/// <returns>The <see cref="DateTime"/> (UTC) encoded in the GUID's timestamp fields.</returns>
/// <exception cref="ArgumentException">
/// Thrown if <paramref name="guid"/> is not a UUID v8.
/// </exception>
/// <remarks>
/// The returned timestamp has 100-microsecond precision (the resolution used by
/// <see cref="NewGuid(DateTime, RngEntropy)"/>). Sub-100µs components of the original
/// <see cref="DateTime"/> are not recoverable.
/// </remarks>
[Pure]
public static DateTime ExtractTimestamp(Guid guid)
{
Span<byte> bytes = stackalloc byte[16];
guid.TryWriteBytes(bytes);
// Version is the high nibble of byte 7 (parameter 'c' high byte, little-endian byte 7)
var version = bytes[7] >> 4;
if (version != 8)
throw new ArgumentException($"The provided GUID is not a UUID v8 (version={version}).", nameof(guid));
// tsHigh is stored as Int32 at bytes 0-3
uint tsHigh = MemoryMarshal.Read<uint>(bytes);
// tsLow is stored as Int16 at bytes 4-5
ushort tsLow = MemoryMarshal.Read<ushort>(bytes[4..]);
long timestamp = ((long)tsHigh << 16) | tsLow;
return DateTime.UnixEpoch.AddTicks(timestamp * TicksPrecision);
}
} }
+9 -6
View File
@@ -48,10 +48,10 @@ public sealed class SeqId(DateTime epoch)
/// <param name="entropy">Entropy quality (default: Strong)</param> /// <param name="entropy">Entropy quality (default: Strong)</param>
/// <returns>64-bit sequential ID with random component</returns> /// <returns>64-bit sequential ID with random component</returns>
/// <exception cref="IndexOutOfRangeException"> /// <exception cref="IndexOutOfRangeException">
/// Thrown if more than 255 IDs generated in 1ms /// Thrown if more than 256 IDs generated in 1ms
/// </exception> /// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long NextId(RngEntropy entropy = RngEntropy.Strong) => Default.Next(DateTime.UtcNow, entropy); public static long NextId(RngEntropy entropy = RngEntropy.Strong) => Default.Next(entropy);
#if NET9_0_OR_GREATER #if NET9_0_OR_GREATER
private readonly Lock _lock = new(); private readonly Lock _lock = new();
@@ -62,6 +62,7 @@ public sealed class SeqId(DateTime epoch)
private readonly DateTime _epoch = epoch; private readonly DateTime _epoch = epoch;
private int _seqId = 0; private int _seqId = 0;
private long _lastTimestamp = -1L; private long _lastTimestamp = -1L;
private Func<DateTime> _defaultTimeFactory = static () => DateTime.UtcNow;
/// <summary> /// <summary>
/// Generates next ID using current UTC time /// Generates next ID using current UTC time
@@ -69,10 +70,10 @@ public sealed class SeqId(DateTime epoch)
/// <param name="entropy">Entropy quality (default: Strong)</param> /// <param name="entropy">Entropy quality (default: Strong)</param>
/// <returns>64-bit sequential ID with random component</returns> /// <returns>64-bit sequential ID with random component</returns>
/// <exception cref="IndexOutOfRangeException"> /// <exception cref="IndexOutOfRangeException">
/// Thrown if more than 255 IDs generated in 1ms /// Thrown if more than 256 IDs generated in 1ms
/// </exception> /// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Next(RngEntropy entropy = RngEntropy.Strong) => Next(DateTime.UtcNow, entropy); public long Next(RngEntropy entropy = RngEntropy.Strong) => Next(_defaultTimeFactory(), entropy);
/// <summary> /// <summary>
/// Generates next ID with explicit timestamp /// Generates next ID with explicit timestamp
@@ -84,7 +85,7 @@ public sealed class SeqId(DateTime epoch)
/// Thrown if <paramref name="dateTime"/> is earlier than last used timestamp /// Thrown if <paramref name="dateTime"/> is earlier than last used timestamp
/// </exception> /// </exception>
/// <exception cref="IndexOutOfRangeException"> /// <exception cref="IndexOutOfRangeException">
/// Thrown if more than 255 IDs generated in 1ms /// Thrown if more than 256 IDs generated in 1ms
/// </exception> /// </exception>
public long Next(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong) public long Next(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong)
{ {
@@ -104,7 +105,7 @@ public sealed class SeqId(DateTime epoch)
throw new InvalidOperationException("Refused to create new SeqId. Last timestamp is in the future."); throw new InvalidOperationException("Refused to create new SeqId. Last timestamp is in the future.");
} }
if (_seqId == SeqMask) if (_seqId > SeqMask)
{ {
throw new IndexOutOfRangeException("Refused to create new SeqId. Sequence exhausted."); throw new IndexOutOfRangeException("Refused to create new SeqId. Sequence exhausted.");
} }
@@ -118,4 +119,6 @@ public sealed class SeqId(DateTime epoch)
return timestamp | currentSeq | currentRand; return timestamp | currentSeq | currentRand;
} }
internal void UnsafeReplaceDefaultTimeFactory(Func<DateTime> timeFactory) => _defaultTimeFactory = timeFactory;
} }
-28
View File
@@ -1,28 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", "{C709D8C9-FE18-4B70-ABE0-57A1850C3398}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Tests", "Core.Tests\Core.Tests.csproj", "{CBA236E8-5CAC-4587-AD7C-A480CD998EB8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C709D8C9-FE18-4B70-ABE0-57A1850C3398}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C709D8C9-FE18-4B70-ABE0-57A1850C3398}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C709D8C9-FE18-4B70-ABE0-57A1850C3398}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C709D8C9-FE18-4B70-ABE0-57A1850C3398}.Release|Any CPU.Build.0 = Release|Any CPU
{CBA236E8-5CAC-4587-AD7C-A480CD998EB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CBA236E8-5CAC-4587-AD7C-A480CD998EB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CBA236E8-5CAC-4587-AD7C-A480CD998EB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CBA236E8-5CAC-4587-AD7C-A480CD998EB8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+4
View File
@@ -0,0 +1,4 @@
<Solution>
<Project Path="Core.Tests/Core.Tests.csproj" />
<Project Path="Core/Core.csproj" />
</Solution>
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2023-2025 JustFixMe Copyright (c) 2023-2026 JustFixMe
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+85 -11
View File
@@ -1,18 +1,92 @@
# .Net library with helpers, functions and extensions # Just.Core
Just some stuff that is used in different projects... Small .NET library with useful helper classes, functions, and extensions — stuff used across multiple projects.
[![NuGet](https://img.shields.io/nuget/v/Just.Core)](https://www.nuget.org/packages/Just.Core)
## Install
```sh
dotnet add package Just.Core
```
## Features ## Features
- ```Map``` and ```DataMap``` collection types ### Encoding
- Extensions for ```System.String``` and ```System.IO.Stream```
- Small converters for **Base32** and **Base64Url** encodings
## Getting Started **Base32** — RFC 4648 encoder/decoder with lowercase and no-padding options.
### Install from NuGet.org ```csharp
var encoded = Base32.Encode(bytes); // "ABCDEFGH..."
```sh var encoded = Base32.Encode(bytes, Base32EncodeOptions.LowerCaseNoPadding);
# install the package using NuGet var bytes = Base32.Decode("ABCDEFGH...");
dotnet add package Just.Core
``` ```
**Base64Url** — URL-safe Base64 for bytes, `long`, and `Guid`.
```csharp
var str = Base64Url.Encode(myGuid); // "5QrdUxDUVkCAEGw8pvLsEw"
var guid = Base64Url.DecodeGuid(str);
var str = Base64Url.Encode(123456789L); // "7NcVAAAAAA"
var val = Base64Url.DecodeLong(str);
```
### IDs
**SeqId** — Time-based 64-bit sequential ID with configurable entropy. Thread-safe.
```csharp
var id = SeqId.NextId(); // default instance, Strong entropy
var id = SeqId.NextId(RngEntropy.Weak); // faster, less collision-resistant
// Custom epoch
var generator = new SeqId(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var id = generator.Next();
```
Structure: `[1 bit reserved][41 bits ms since epoch][8 bits sequence][14 bits random]`
**GuidV8** — Time-sortable UUID v8 with extractable timestamp.
```csharp
var guid = GuidV8.NewGuid(); // current UTC, Strong entropy
var guid = GuidV8.NewGuid(specificTime);
var when = GuidV8.ExtractTimestamp(guid); // recover timestamp (100µs precision)
```
### Collections
**Map&lt;T&gt; / DataMap&lt;T&gt;** — 2D grid container with indexing, enumeration, and min/max.
```csharp
var map = new Map<int>(width: 10, height: 10, values);
var value = map[3, 5]; // integer or double coordinates
var point = map.Get(3.5, 5.2); // MapPoint<T> with position metadata
var dmap = new DataMap<double>(values, 10, 10);
var min = dmap.Min; // MapPoint<double> — cached
var max = dmap.Max; // lazy, thread-safe on first access
// Stream serialization (sync + async)
dmap.WriteToStream(stream);
var copy = DataMap.ReadFromStream<double>(stream);
```
**ImmutableSequence&lt;T&gt;** — Immutable list with structural value equality.
```csharp
var seq = new ImmutableSequence<int>([1, 2, 3]);
var seq2 = seq.Add(4); // new instance, seq unchanged
seq == seq2; // false — 3 vs 4 elements
new ImmutableSequence<int>([1, 2, 3]) == seq; // true — value equality
```
## Supported Targets
- .NET 8.0 (LTS)
- .NET 9.0
- .NET 10.0
## License
MIT — see [LICENSE](LICENSE).