12 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
just 312219d42f minor refactoring and tests
.NET Test / test (push) Successful in 45s
.NET Publish / publish (push) Successful in 42s
2025-08-06 22:02:38 +04:00
just 7eb3008738 guid generation fix
.NET Test / test (push) Successful in 1m2s
2025-08-06 21:59:44 +04:00
just 3665abaab8 dotnet 9 and sequential id
.NET Test / test (push) Successful in 49s
.NET Publish / publish (push) Successful in 41s
2025-08-01 22:21:42 +04:00
32 changed files with 1441 additions and 309 deletions
+6 -3
View File
@@ -10,13 +10,16 @@ jobs:
publish:
runs-on: ubuntu-latest
env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5
- name: Setup .NET
uses: https://github.com/actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Restore dependencies
run: dotnet restore Core/Core.csproj
+30 -10
View File
@@ -6,6 +6,7 @@ on:
tags-ignore:
- '**'
paths-ignore:
- 'LICENSE'
- 'README.md'
- '.gitea/workflows/publish-*.yaml'
pull_request:
@@ -14,28 +15,47 @@ on:
jobs:
test:
runs-on: ubuntu-latest
name: .NET tests
env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5
- name: Setup .NET
uses: https://github.com/actions/setup-dotnet@v3
uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: |
8.0.x
9.0.x
10.0.x
- name: Restore dependencies
run: dotnet restore
run: dotnet restore --disable-parallel
- name: Build
run: dotnet build --no-restore
- name: Build .NET 10.0
run: dotnet build --no-restore --framework net10.0 --configuration Release ./Core.Tests/Core.Tests.csproj
- name: Test
run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults-8.x"
- name: Build .NET 9.0
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
uses: actions/upload-artifact@v3
with:
name: dotnet-results-8.x
path: TestResults-8.x
name: test-results
path: TestResults
if: ${{ always() }}
retention-days: 30
+5 -2
View File
@@ -1,4 +1,7 @@
{
"dotnet.defaultSolution": "JustDotNet.Core.sln",
"dotnetAcquisitionExtension.enableTelemetry": false
"dotnet.defaultSolution": "JustDotNet.Core.slnx",
"omnisharp.enableEditorConfigSupport": true,
"dotnetAcquisitionExtension.enableTelemetry": false,
"dotnet.testWindow.useTestingPlatformProtocol": true,
"dotnet.formatting.organizeImportsOnFormat": true
}
+94 -17
View File
@@ -3,11 +3,31 @@ namespace Just.Core.Tests.Base32Conversions;
public class Decode
{
[Theory]
[InlineData(15243)]
[InlineData(812010)]
[InlineData(97331334)]
[InlineData(20354)]
public void WhenEncodedToString_ShouldBeDecodedToTheSameByteArray(int seed)
[InlineData(15243, Base32EncodeOptions.None)]
[InlineData(15243, Base32EncodeOptions.LowerCase)]
[InlineData(15243, Base32EncodeOptions.NoPadding)]
[InlineData(15243, Base32EncodeOptions.LowerCaseNoPadding)]
[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);
@@ -16,25 +36,33 @@ public class Decode
var testBytes = new byte[i];
rng.NextBytes(testBytes);
var resultString = Base32.Encode(testBytes);
var resultString = Base32.Encode(testBytes, options);
var resultBytes = Base32.Decode(resultString);
resultBytes.Should().BeEquivalentTo(testBytes);
resultBytes.ShouldBeEquivalentTo(testBytes);
}
}
[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("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("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("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("74OQ====", new byte[] { 0b11111111, 0b00011101, })]
public void WhenCalledWithValidString_ShouldReturnValidByteArray(string str, byte[] expected)
{
var actualBytesArray = Base32.Decode(str);
actualBytesArray.Should().Equal(expected);
actualBytesArray.ShouldBe(expected);
}
[Theory]
@@ -44,7 +72,7 @@ public class Decode
public void WhenCalledWithValidStringThatEndsWithPaddingSign_ShouldReturnValidByteArray(string testString, byte[] expected)
{
var actualBytesArray = Base32.Decode(testString);
actualBytesArray.Should().Equal(expected);
actualBytesArray.ShouldBe(expected);
}
[Theory]
@@ -54,26 +82,75 @@ public class Decode
public void WhenCalledWithValidStringWithoutPaddingSign_ShouldReturnValidByteArray(string testString, byte[] expected)
{
var actualBytesArray = Base32.Decode(testString);
actualBytesArray.Should().Equal(expected);
actualBytesArray.ShouldBe(expected);
}
[Theory]
[InlineData(" ")]
[InlineData("hg2515i3215")]
[InlineData("hg2515i3215q")]
[InlineData("hg712)21")]
[InlineData("hg712f 21")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1806:Do not ignore method results", Justification = "Test case")]
[InlineData("hg712f 211")]
[InlineData("AEBAGB^F")]
public void WhenCalledWithNotValidString_ShouldThrowFormatException(string testString)
{
Action action = () => Base32.Decode(testString);
action.Should().Throw<FormatException>();
Action action = () => _ = Base32.Decode(testString);
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]
[InlineData(null)]
[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')");
}
}
+53 -7
View File
@@ -4,6 +4,7 @@ public class Encode
{
[Theory]
[InlineData("TQ======")]
[InlineData("CE======")]
[InlineData("3X3A====")]
[InlineData("426G6===")]
[InlineData("C3V3Y===")]
@@ -23,17 +24,24 @@ public class Encode
[InlineData("GSHXGB5ORKNDSFLSU2YWALI=")]
[InlineData("TMFSC64ZZNPQSNGCFIODS7TR")]
[InlineData("ZTDUBU4QZFFMDJKBII334EIB")]
[InlineData("2IO2HTALCXZWCBD2AAAAAAAAAAAA====")]
[InlineData("WXYEOQUZULMCY6ZQTDOLTRUZZMKQ====")]
[InlineData("FG4M3ZQM3TVWDMBUP5L7N7V3JS7KBM2E")]
public void WhenDecodedFromString_ShouldBeEncodedToTheSameString(string testString)
{
var resultBytes = Base32.Decode(testString);
var resultString = Base32.Encode(resultBytes);
var resultStringLowerCase = Base32.Encode(resultBytes, Base32EncodeOptions.LowerCase);
resultString.Should().Be(testString);
resultString.ShouldBe(testString);
resultStringLowerCase.ShouldBe(testString.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, })]
@@ -42,28 +50,66 @@ public class Encode
public void WhenCalledWithNotEmptyByteArray_ShouldReturnValidString(string expected, byte[] 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]
[InlineData(new byte[] { })]
[InlineData(null)]
public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[] testArray)
public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[]? testArray)
{
var actualBase32 = Base32.Encode(testArray);
actualBase32.Should().Be(string.Empty);
actualBase32.ShouldBe(string.Empty);
}
[Theory]
[InlineData(new byte[] { })]
[InlineData(null)]
public void WhenCalledWithEmptyByteArray_ShouldReturnZeroAndNotChangeOutput(byte[] testArray)
public void WhenCalledWithEmptyByteArray_ShouldReturnZeroAndNotChangeOutput(byte[]? testArray)
{
char[] output = ['1', '2', '3', '4'];
var charsWritten = Base32.Encode(testArray, output);
charsWritten.Should().Be(0);
output.Should().Equal(['1', '2', '3', '4']);
charsWritten.ShouldBe(0);
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')");
}
}
+58 -8
View File
@@ -7,7 +7,7 @@ public class Decode
[InlineData(554121)]
[InlineData(100454567)]
[InlineData(3210589)]
public void WhenEncodedToString_ShouldBeDecodedToTheSameByteArray(int seed)
public void WhenBytesEncodedToString_ShouldBeDecodedToTheSameByteArray(int seed)
{
var rng = new Random(seed);
@@ -19,10 +19,47 @@ public class Decode
var resultString = Base64Url.Encode(testBytes);
var resultBytes = Base64Url.Decode(resultString);
resultBytes.Should().BeEquivalentTo(testBytes);
resultBytes.ShouldBeEquivalentTo(testBytes);
}
}
[Theory]
[InlineData(72121)]
[InlineData(554121)]
[InlineData(100454567)]
[InlineData(3210589)]
public void WhenLongEncodedToString_ShouldBeDecodedToTheSameLongArray(int seed)
{
var rng = new Random(seed);
for (int i = 1; i <= 512; i++)
{
var testLong = rng.NextInt64();
var resultString = Base64Url.Encode(testLong);
var resultLong = Base64Url.DecodeLong(resultString);
resultLong.ShouldBe(testLong);
}
}
[Theory]
[InlineData("RgGxr0_n1ZI", -7866126844696657594L)]
[InlineData("sxAPfpKB5kY", 5108913293478531251L)]
[InlineData("lO4_uitvLCg", 2894810894091415188L)]
[InlineData("awxjIqZWz10", 6759716837247880299L)]
[InlineData("VjNe72vug4U", -8825948697371200682L)]
[InlineData("AAAAAAAAAAA", 0L)]
[InlineData("__________8", -1L)]
[InlineData("AQAAAAAAAAA", 1L)]
[InlineData("CgAAAAAAAAA", 10L)]
[InlineData("6AMAAAAAAAA", 1000L)]
public void WhenCalled_ShouldReturnValidLong(string testString, long expected)
{
var result = Base64Url.DecodeLong(testString);
result.ShouldBe(expected);
}
[Theory]
[InlineData("5QrdUxDUVkCAEGw8pvLsEw", "53dd0ae5-d410-4056-8010-6c3ca6f2ec13")]
[InlineData("6nE2uKQ4_0ar9kpmybgkdw", "b83671ea-38a4-46ff-abf6-4a66c9b82477")]
@@ -33,7 +70,7 @@ public class Decode
{
var result = Base64Url.DecodeGuid(testString);
var expected = Guid.Parse(expectedStr);
result.Should().Be(expected);
result.ShouldBe(expected);
}
[Theory]
@@ -48,7 +85,7 @@ public class Decode
public void WhenCalled_ShouldReturnValidBytes(string testString, byte[] expected)
{
var result = Base64Url.Decode(testString);
result.Should().BeEquivalentTo(expected);
result.ShouldBeEquivalentTo(expected);
}
[Theory]
@@ -60,7 +97,7 @@ public class Decode
public void WhenCalledWithInvalidString_ShouldThrowFormatException(string testString)
{
Action action = () => Base64Url.Decode(testString);
action.Should().Throw<FormatException>();
action.ShouldThrow<FormatException>();
}
[Theory]
@@ -72,14 +109,27 @@ public class Decode
public void WhenCalledWithInvalidGuidString_ShouldThrowFormatException(string testString)
{
Action action = () => Base64Url.DecodeGuid(testString);
action.Should().Throw<FormatException>();
action.ShouldThrow<FormatException>();
}
[Theory]
[InlineData("RgG&r0_n1ZI")]
[InlineData("sxA fpKB5kY")]
[InlineData("lO4_uitvL)g")]
[InlineData("awxjIqZ^z10")]
[InlineData("VjNe7!vug4U")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1806:Do not ignore method results", Justification = "Test case")]
public void WhenCalledWithInvalidLongString_ShouldThrowFormatException(string testString)
{
Action action = () => Base64Url.DecodeLong(testString);
action.ShouldThrow<FormatException>();
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void WhenCalledWithNullString_ShouldReturnEmptyArray(string testString)
public void WhenCalledWithNullString_ShouldReturnEmptyArray(string? testString)
{
Base64Url.Decode(testString).Should().BeEmpty();
Base64Url.Decode(testString).ShouldBeEmpty();
}
}
+24 -7
View File
@@ -12,7 +12,24 @@ public class Encode
{
var testGuid = Guid.Parse(testGuidString);
var result = Base64Url.Encode(testGuid);
result.Should().Be(expected);
result.ShouldBe(expected);
}
[Theory]
[InlineData("RgGxr0_n1ZI", -7866126844696657594L)]
[InlineData("sxAPfpKB5kY", 5108913293478531251L)]
[InlineData("lO4_uitvLCg", 2894810894091415188L)]
[InlineData("awxjIqZWz10", 6759716837247880299L)]
[InlineData("VjNe72vug4U", -8825948697371200682L)]
[InlineData("AAAAAAAAAAA", 0L)]
[InlineData("__________8", -1L)]
[InlineData("AQAAAAAAAAA", 1L)]
[InlineData("CgAAAAAAAAA", 10L)]
[InlineData("6AMAAAAAAAA", 1000L)]
public void WhenCalledWithLong_ShouldReturnValidString(string expected, long testLong)
{
var result = Base64Url.Encode(testLong);
result.ShouldBe(expected);
}
[Theory]
@@ -27,28 +44,28 @@ public class Encode
public void WhenCalled_ShouldReturnValidString(string expected, byte[] testBytes)
{
var result = Base64Url.Encode(testBytes);
result.Should().Be(expected);
result.ShouldBe(expected);
}
[Theory]
[InlineData(new byte[] { })]
[InlineData(null)]
public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[] testArray)
public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[]? testArray)
{
var actualBase32 = Base64Url.Encode(testArray);
actualBase32.Should().Be(string.Empty);
actualBase32.ShouldBe(string.Empty);
}
[Theory]
[InlineData(new byte[] { })]
[InlineData(null)]
public void WhenCalledWithEmptyByteArray_ShouldReturnZeroAndNotChangeOutput(byte[] testArray)
public void WhenCalledWithEmptyByteArray_ShouldReturnZeroAndNotChangeOutput(byte[]? testArray)
{
char[] output = ['1', '2', '3', '4'];
var charsWritten = Base64Url.Encode(testArray, output);
charsWritten.Should().Be(0);
output.Should().Equal(['1', '2', '3', '4']);
charsWritten.ShouldBe(0);
output.ShouldBe((char[])['1', '2', '3', '4']);
}
}
+5 -9
View File
@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<AssemblyName>Just.Core.Tests</AssemblyName>
<RootNamespace>Just.Core.Tests</RootNamespace>
@@ -14,14 +15,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="coverlet.collector" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
+1 -1
View File
@@ -1,2 +1,2 @@
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]);
}
}
+89 -61
View File
@@ -3,15 +3,43 @@ namespace Just.Core.Tests.GuidV8Tests;
public class NewGuid
{
[Theory]
[InlineData(GuidV8Entropy.Weak,
-25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
[InlineData(RngEntropy.Weak)]
[InlineData(RngEntropy.Strong)]
public void Version_And_Variant_Should_Be_Correct(RngEntropy entropy)
{
var rng = new Random(25);
var referenceTime = new DateTime(2020, 05, 17, 15, 36, 13, 771, DateTimeKind.Utc);
for (int i = 0; i < 2000; i++)
{
var timestamp = referenceTime.AddSeconds(rng.Next());
var result = GuidV8.NewGuid(timestamp, entropy);
#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
}
}
[Theory]
[InlineData(RngEntropy.Weak,
-25000000, -20000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
-2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 250000000)]
[InlineData(GuidV8Entropy.Strong,
-25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 20000000, 50000000, 100000000)]
[InlineData(RngEntropy.Strong,
-25000000, -20000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
-2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 250000000)]
[InlineData(GuidV8Entropy.Weak,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 20000000, 50000000, 100000000)]
[InlineData(RngEntropy.Weak,
-9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449,
-8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855,
-6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454,
@@ -22,7 +50,7 @@ public class NewGuid
4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979,
6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099,
8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)]
[InlineData(GuidV8Entropy.Strong,
[InlineData(RngEntropy.Strong,
-9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449,
-8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855,
-6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454,
@@ -33,29 +61,29 @@ public class NewGuid
4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979,
6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099,
8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)]
[InlineData(GuidV8Entropy.Weak,
5635912, 6673780, 17277183, 17512959, 19098799, 21672621, 30581958, 30824885, 31874213, 35192781,
36337094, 37752116, 38387215, 39154682, 40525427, 52288093, 55218356, 59065156, 65231785, 75430932,
76289058, 79078058, 85770685, 85925884, 94726743, 94864163, 95781967, 96150006, 96482085, 102570414,
107768232, 110571078, 110680108, 117974892, 119800380, 126381415, 135895862, 140034471, 149039187, 150906974,
156853001, 160514433, 166446323, 170148965, 171759448, 176494242, 184537553, 188558155, 197194403, 197615804,
201195323, 202294490, 203040975, 203331457, 205016944, 213460258, 217072025, 217185345, 231344025, 232390198,
235053215, 240175073, 245030721, 252275255, 252310334, 277070940, 277359970, 280624756, 288601124, 292427106,
292563035, 299285016, 303834917, 310357836, 315078337, 316367236, 318311758, 318873972, 319675272, 321784171,
324204294, 327667283, 330287252, 338438172, 349863360, 360777768, 366398711, 368637150, 368776734, 371900343,
379094084, 379818879, 381448333, 381814627, 382393101, 382483709, 385600870, 389455134, 396115960, 399364095)]
[InlineData(GuidV8Entropy.Strong,
5635912, 6673780, 17277183, 17512959, 19098799, 21672621, 30581958, 30824885, 31874213, 35192781,
36337094, 37752116, 38387215, 39154682, 40525427, 52288093, 55218356, 59065156, 65231785, 75430932,
76289058, 79078058, 85770685, 85925884, 94726743, 94864163, 95781967, 96150006, 96482085, 102570414,
107768232, 110571078, 110680108, 117974892, 119800380, 126381415, 135895862, 140034471, 149039187, 150906974,
156853001, 160514433, 166446323, 170148965, 171759448, 176494242, 184537553, 188558155, 197194403, 197615804,
201195323, 202294490, 203040975, 203331457, 205016944, 213460258, 217072025, 217185345, 231344025, 232390198,
235053215, 240175073, 245030721, 252275255, 252310334, 277070940, 277359970, 280624756, 288601124, 292427106,
292563035, 299285016, 303834917, 310357836, 315078337, 316367236, 318311758, 318873972, 319675272, 321784171,
324204294, 327667283, 330287252, 338438172, 349863360, 360777768, 366398711, 368637150, 368776734, 371900343,
379094084, 379818879, 381448333, 381814627, 382393101, 382483709, 385600870, 389455134, 396115960, 399364095)]
public void Guids_Differing_By_Minutes_Should_Be_Sortable(GuidV8Entropy entropy, params int[] seconds)
[InlineData(RngEntropy.Weak,
10024, 28660, 289641, 356015, 443164, 478759, 599586, 705860, 791271, 876512,
884503, 894899, 898584, 980136, 1007927, 1680328, 1690193, 1804615, 1847117, 2005534,
2106684, 2111936, 2252935, 2271396, 2298685, 2385409, 2414094, 2451706, 2549138, 2605538,
2864629, 2923476, 3004288, 3182875, 3266693, 3379019, 3542110, 3851467, 3871420, 4035463,
4316004, 4726381, 4814068, 4902666, 4979292, 4993443, 5117765, 5240585, 5249671, 5319528,
5387595, 5434544, 5504506, 5531264, 5546173, 5780381, 5889341, 6066328, 6167883, 6185073,
6299021, 6412136, 6621498, 6666562, 6741169, 6870279, 6949855, 6965427, 7153467, 7184150,
7300456, 7311381, 7413697, 7505235, 7802811, 7979134, 8053665, 8177676, 8260284, 8260773,
8269944, 8406526, 8442932, 8475162, 8555250, 8853347, 8861733, 8892200, 9069869, 9117839,
9225445, 9245837, 9378644, 9497874, 9553625, 9650968, 9704053, 9713592, 9715054, 9735988)]
[InlineData(RngEntropy.Strong,
10024, 28660, 289641, 356015, 443164, 478759, 599586, 705860, 791271, 876512,
884503, 894899, 898584, 980136, 1007927, 1680328, 1690193, 1804615, 1847117, 2005534,
2106684, 2111936, 2252935, 2271396, 2298685, 2385409, 2414094, 2451706, 2549138, 2605538,
2864629, 2923476, 3004288, 3182875, 3266693, 3379019, 3542110, 3851467, 3871420, 4035463,
4316004, 4726381, 4814068, 4902666, 4979292, 4993443, 5117765, 5240585, 5249671, 5319528,
5387595, 5434544, 5504506, 5531264, 5546173, 5780381, 5889341, 6066328, 6167883, 6185073,
6299021, 6412136, 6621498, 6666562, 6741169, 6870279, 6949855, 6965427, 7153467, 7184150,
7300456, 7311381, 7413697, 7505235, 7802811, 7979134, 8053665, 8177676, 8260284, 8260773,
8269944, 8406526, 8442932, 8475162, 8555250, 8853347, 8861733, 8892200, 9069869, 9117839,
9225445, 9245837, 9378644, 9497874, 9553625, 9650968, 9704053, 9713592, 9715054, 9735988)]
public void Guids_Differing_By_Minutes_Should_Be_Sortable(RngEntropy entropy, params int[] seconds)
{
var rng = new Random(25);
var referenceTime = new DateTime(2024, 05, 17, 15, 36, 13, 771, DateTimeKind.Utc);
@@ -69,23 +97,23 @@ public class NewGuid
var sut = expected.Values.ToArray();
rng.Shuffle(sut);
sut.Order().Should().Equal(expected.Select(x => x.Value));
sut.OrderBy(x => x.ToString()).Should().Equal(expected.Select(x => x.Value));
sut.Order().ShouldBe(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.OrderByDescending(x => x.ToString()).Should().Equal(expected.Reverse().Select(x => x.Value));
sut.OrderDescending().ShouldBe(expected.Reverse().Select(x => x.Value));
sut.OrderByDescending(x => x.ToString()).ShouldBe(expected.Reverse().Select(x => x.Value));
}
[Theory]
[InlineData(GuidV8Entropy.Weak,
-250000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
[InlineData(RngEntropy.Weak,
-100000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
-2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 2100000000)]
[InlineData(GuidV8Entropy.Strong,
-250000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 200000000)]
[InlineData(RngEntropy.Strong,
-100000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
-2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 2100000000)]
[InlineData(GuidV8Entropy.Weak,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 200000000)]
[InlineData(RngEntropy.Weak,
-9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449,
-8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855,
-6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454,
@@ -96,7 +124,7 @@ public class NewGuid
4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979,
6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099,
8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)]
[InlineData(GuidV8Entropy.Strong,
[InlineData(RngEntropy.Strong,
-9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449,
-8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855,
-6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454,
@@ -107,7 +135,7 @@ public class NewGuid
4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979,
6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099,
8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)]
[InlineData(GuidV8Entropy.Weak,
[InlineData(RngEntropy.Weak,
6629058, 24114993, 40561510, 46245969, 46876997, 48747281, 80489854, 110237218, 117445694, 118974860,
135132579, 141760591, 149114066, 158322437, 159065333, 164925904, 173848639, 175086337, 175704556, 176335514,
200302773, 207133553, 230088723, 234521706, 239587338, 263755571, 264571928, 290118284, 292346548, 319322378,
@@ -118,7 +146,7 @@ public class NewGuid
749379956, 757561933, 758668417, 761735205, 770349479, 797570403, 805896481, 809050934, 821655964, 821980469,
830824227, 840429528, 851772315, 859717719, 859763860, 867675943, 912124563, 914880620, 923914294, 930298008,
932610035, 937468680, 945565998, 949277691, 949397209, 951283050, 953249971, 953953188, 976210158, 982233484, 982233485)]
[InlineData(GuidV8Entropy.Strong,
[InlineData(RngEntropy.Strong,
6629058, 24114993, 40561510, 46245969, 46876997, 48747281, 80489854, 110237218, 117445694, 118974860,
135132579, 141760591, 149114066, 158322437, 159065333, 164925904, 173848639, 175086337, 175704556, 176335514,
200302773, 207133553, 230088723, 234521706, 239587338, 263755571, 264571928, 290118284, 292346548, 319322378,
@@ -129,7 +157,7 @@ public class NewGuid
749379956, 757561933, 758668417, 761735205, 770349479, 797570403, 805896481, 809050934, 821655964, 821980469,
830824227, 840429528, 851772315, 859717719, 859763860, 867675943, 912124563, 914880620, 923914294, 930298008,
932610035, 937468680, 945565998, 949277691, 949397209, 951283050, 953249971, 953953188, 976210158, 982233484, 982233485)]
public void Guids_Differing_By_Seconds_Should_Be_Sortable(GuidV8Entropy entropy, params int[] seconds)
public void Guids_Differing_By_Seconds_Should_Be_Sortable(RngEntropy entropy, params int[] seconds)
{
var rng = new Random(25);
var referenceTime = new DateTime(2024, 05, 17, 15, 36, 13, 771, DateTimeKind.Utc);
@@ -143,23 +171,23 @@ public class NewGuid
var sut = expected.Values.ToArray();
rng.Shuffle(sut);
sut.Order().Should().Equal(expected.Select(x => x.Value));
sut.OrderBy(x => x.ToString()).Should().Equal(expected.Select(x => x.Value));
sut.Order().ShouldBe(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.OrderByDescending(x => x.ToString()).Should().Equal(expected.Reverse().Select(x => x.Value));
sut.OrderDescending().ShouldBe(expected.Reverse().Select(x => x.Value));
sut.OrderByDescending(x => x.ToString()).ShouldBe(expected.Reverse().Select(x => x.Value));
}
[Theory]
[InlineData(GuidV8Entropy.Weak,
[InlineData(RngEntropy.Weak,
-250000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
-2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 2100000000)]
[InlineData(GuidV8Entropy.Strong,
[InlineData(RngEntropy.Strong,
-250000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000,
-2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000,
2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 2100000000)]
[InlineData(GuidV8Entropy.Weak,
[InlineData(RngEntropy.Weak,
-9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449,
-8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855,
-6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454,
@@ -170,7 +198,7 @@ public class NewGuid
4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979,
6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099,
8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)]
[InlineData(GuidV8Entropy.Strong,
[InlineData(RngEntropy.Strong,
-9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449,
-8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855,
-6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454,
@@ -181,7 +209,7 @@ public class NewGuid
4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979,
6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099,
8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)]
[InlineData(GuidV8Entropy.Weak,
[InlineData(RngEntropy.Weak,
6629058, 24114993, 40561510, 46245969, 46876997, 48747281, 80489854, 110237218, 117445694, 118974860,
135132579, 141760591, 149114066, 158322437, 159065333, 164925904, 173848639, 175086337, 175704556, 176335514,
200302773, 207133553, 230088723, 234521706, 239587338, 263755571, 264571928, 290118284, 292346548, 319322378,
@@ -192,7 +220,7 @@ public class NewGuid
749379956, 757561933, 758668417, 761735205, 770349479, 797570403, 805896481, 809050934, 821655964, 821980469,
830824227, 840429528, 851772315, 859717719, 859763860, 867675943, 912124563, 914880620, 923914294, 930298008,
932610035, 937468680, 945565998, 949277691, 949397209, 951283050, 953249971, 953953188, 976210158, 982233484, 982233485)]
[InlineData(GuidV8Entropy.Strong,
[InlineData(RngEntropy.Strong,
6629058, 24114993, 40561510, 46245969, 46876997, 48747281, 80489854, 110237218, 117445694, 118974860,
135132579, 141760591, 149114066, 158322437, 159065333, 164925904, 173848639, 175086337, 175704556, 176335514,
200302773, 207133553, 230088723, 234521706, 239587338, 263755571, 264571928, 290118284, 292346548, 319322378,
@@ -203,7 +231,7 @@ public class NewGuid
749379956, 757561933, 758668417, 761735205, 770349479, 797570403, 805896481, 809050934, 821655964, 821980469,
830824227, 840429528, 851772315, 859717719, 859763860, 867675943, 912124563, 914880620, 923914294, 930298008,
932610035, 937468680, 945565998, 949277691, 949397209, 951283050, 953249971, 953953188, 976210158, 982233484, 982233485)]
public void Guids_Differing_By_Milliseconds_Should_Be_Sortable(GuidV8Entropy entropy, params int[] seconds)
public void Guids_Differing_By_Milliseconds_Should_Be_Sortable(RngEntropy entropy, params int[] seconds)
{
var rng = new Random(26);
var referenceTime = new DateTime(2024, 05, 17, 15, 36, 13, 771, DateTimeKind.Utc);
@@ -217,10 +245,10 @@ public class NewGuid
var sut = expected.Values.ToArray();
rng.Shuffle(sut);
sut.Order().Should().Equal(expected.Select(x => x.Value));
sut.OrderBy(x => x.ToString()).Should().Equal(expected.Select(x => x.Value));
sut.Order().ShouldBe(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.OrderByDescending(x => x.ToString()).Should().Equal(expected.Reverse().Select(x => x.Value));
sut.OrderDescending().ShouldBe(expected.Reverse().Select(x => x.Value));
sut.OrderByDescending(x => x.ToString()).ShouldBe(expected.Reverse().Select(x => x.Value));
}
}
+144
View File
@@ -0,0 +1,144 @@
namespace Just.Core.Tests.SeqIdTests;
public class NextId
{
private static readonly DateTime TestEpoch = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private readonly SeqId _seqId = new(TestEpoch);
private const int TimestampShift = 22; // 63 - 41 = 22
private const long TimestampMask = 0x1FFFFFFFFFF; // 41 bits mask
private const int SeqShift = 14; // TimestampShift - 8 = 14
private const long SeqMask = 0xFF; // 8 bits mask
private const long RandMask = 0x3FFF; // 14 bits mask (since 2^14 = 16384)
[Fact]
public void NextId_ShouldHaveCorrectBitStructure()
{
// Arrange
var time = TestEpoch.AddMilliseconds(500);
// Act
long id = _seqId.Next(time);
// Assert
long timestampPart = (id >> TimestampShift) & TimestampMask;
long sequencePart = (id >> SeqShift) & SeqMask;
long randomPart = id & RandMask;
timestampPart.ShouldBe(500);
sequencePart.ShouldBe(0);
randomPart.ShouldBeInRange(0, RandMask);
}
[Fact]
public void NextId_ShouldIncrementSequenceForSameTimestamp()
{
// Arrange
var time = TestEpoch.AddMilliseconds(100);
// Act
long id1 = _seqId.Next(time);
long id2 = _seqId.Next(time);
// Assert
long sequence1 = (id1 >> SeqShift) & SeqMask;
long sequence2 = (id2 >> SeqShift) & SeqMask;
sequence2.ShouldBe(sequence1 + 1);
}
[Fact]
public void NextId_ShouldResetSequenceForNewTimestamp()
{
// Arrange
var time1 = TestEpoch.AddMilliseconds(100);
var time2 = time1.AddMilliseconds(1);
// Act
_ = _seqId.Next(time1); // Sequence increments to 0
_ = _seqId.Next(time1); // Sequence increments to 1
long id = _seqId.Next(time2); // Should reset to 0
// Assert
long sequence = (id >> SeqShift) & SeqMask;
sequence.ShouldBe(0);
}
[Fact]
public void NextId_ShouldThrowWhenTimestampDecreases()
{
// Arrange
var time1 = TestEpoch.AddMilliseconds(200);
var time2 = TestEpoch.AddMilliseconds(100);
// Act & Assert
_seqId.Next(time1); // First call sets last timestamp
Action act = () => _seqId.Next(time2);
act.ShouldThrow<InvalidOperationException>()
.WithMessage("Refused to create new SeqId. Last timestamp is in the future.");
}
[Fact]
public void NextId_ShouldThrowWhenSequenceExhausted()
{
// Arrange
var time = TestEpoch.AddMilliseconds(200);
// Act & Assert
for (int i = 0; i < 256; i++)
{
_seqId.Next(time); // Use all 256 sequence values (0-255)
}
Action act = () => _seqId.Next(time); // 257th call should throw
act.ShouldThrow<IndexOutOfRangeException>()
.WithMessage("Refused to create new SeqId. Sequence exhausted.");
}
[Fact]
public void NextId_WithStrongEntropy_ShouldSetLower14Bits()
{
// Arrange
var time = TestEpoch.AddMilliseconds(300);
// Act
long id = _seqId.Next(time, RngEntropy.Strong);
// Assert
long randomPart = id & RandMask;
randomPart.ShouldBeInRange(0, RandMask);
}
[Fact]
public void NextId_WithWeakEntropy_ShouldSetLower14Bits()
{
// Arrange
var time = TestEpoch.AddMilliseconds(400);
// Act
long id = _seqId.Next(time, RngEntropy.Weak);
// Assert
long randomPart = id & RandMask;
randomPart.ShouldBeInRange(0, RandMask);
}
[Fact]
public void DefaultInstance_NextId_ShouldUseDefaultEpoch()
{
// Arrange
var now = new DateTime(2026, 6, 3, 21, 11, 1, DateTimeKind.Utc);
SeqId.Default.UnsafeReplaceDefaultTimeFactory(() => now);
// Act
long id = SeqId.NextId();
// Assert
long expectedTimestamp = GetExpectedTimestamp(now);
long timestampPart = (id >> TimestampShift) & TimestampMask;
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);
buffer.Skip(offset).Take(length).Should().Equal(streamContent.Take(length));
buffer.Skip(offset).Take(length).ShouldBe(streamContent.Take(length));
}
[Theory]
@@ -38,7 +38,7 @@ public class Populate
stream.Populate(buffer);
buffer.Should().Equal(streamContent.Take(bufferSize));
buffer.ShouldBe(streamContent.Take(bufferSize));
}
[Theory]
@@ -53,6 +53,6 @@ public class Populate
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);
cts.Cancel();
await action.Should().ThrowAsync<OperationCanceledException>();
await action.ShouldThrowAsync<OperationCanceledException>();
}
[Theory]
@@ -31,13 +31,14 @@ public class PopulateAsync
[InlineData(5, 5)]
public async Task WhenCalled_ShouldPopulateSpecifiedRange(int offset, int length)
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
byte[] streamContent = [0x01, 0x02, 0x03, 0x04, 0x05,];
using var stream = new MemoryStream(streamContent);
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]
@@ -47,12 +48,13 @@ public class PopulateAsync
[InlineData(new byte[]{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, }, 5)]
public async Task WhenStreamContainsSameOrGreaterAmmountOfItems_ShouldPopulateBuffer(byte[] streamContent, int bufferSize)
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
using var stream = new MemoryStream(streamContent);
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]
@@ -67,6 +69,6 @@ public class PopulateAsync
Func<Task> action = async () => await stream.PopulateAsync(buffer);
await action.Should().ThrowAsync<EndOfStreamException>();
await action.ShouldThrowAsync<EndOfStreamException>();
}
}
+143 -34
View File
@@ -1,55 +1,139 @@
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
{
/// <summary>RFC 4648 Base32 alphabet (uppercase A-Z, 2-7).</summary>
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 = '=';
/// <summary>Maximum input byte count that uses stack allocation instead of heap.</summary>
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]
public static string Encode(ReadOnlySpan<byte> input)
public static string Encode(ReadOnlySpan<byte> input, Base32EncodeOptions options = Base32EncodeOptions.None)
{
if (input.Length == 0)
{
return string.Empty;
}
if (input.IsEmpty) return string.Empty;
int outLength = 8 * ((input.Length + 4) / 5);
Span<char> output = stackalloc char[outLength];
Span<char> output = input.Length > MaxBytesStack
? new char[outLength]
: stackalloc char[outLength];
_ = Encode(input, output);
var size = Encode(input, output, options);
return new string(output);
return new string(output[..size]);
}
[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;
int outputLength = 8 * ((input.Length + 4) / 5);
if (output.Length < outputLength)
{
throw new ArgumentException("Encoded input can not fit in output span.", nameof(output));
}
output = output[..outputLength];
ReadOnlySpan<char> alphabet = (options & Base32EncodeOptions.LowerCase) == Base32EncodeOptions.LowerCase
? AlphabetLower
: Alphabet;
Span<byte> alphabetKeys = stackalloc byte[8];
int i = 0;
ReadOnlySpan<char> alphabet = Alphabet;
for (int offset = 0; offset < input.Length;)
{
int numCharsToOutput = GetNextGroup(input, ref offset, out byte a, out byte b, out byte c, out byte d, out byte e, out byte f, out byte g, out byte h);
alphabetKeys.Clear();
int numCharsToOutput = GetNextGroup(input, ref offset, alphabetKeys);
output[i++] = (numCharsToOutput > 0) ? alphabet[a] : Padding;
output[i++] = (numCharsToOutput > 1) ? alphabet[b] : Padding;
output[i++] = (numCharsToOutput > 2) ? alphabet[c] : Padding;
output[i++] = (numCharsToOutput > 3) ? alphabet[d] : Padding;
output[i++] = (numCharsToOutput > 4) ? alphabet[e] : Padding;
output[i++] = (numCharsToOutput > 5) ? alphabet[f] : Padding;
output[i++] = (numCharsToOutput > 6) ? alphabet[g] : Padding;
output[i++] = (numCharsToOutput > 7) ? alphabet[h] : Padding;
output[i++] = alphabet[alphabetKeys[0]];
output[i++] = alphabet[alphabetKeys[1]];
if (numCharsToOutput < 3)
{
i = FillWithPadding(output, i, numCharsToOutput, options);
break;
}
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;
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]
public static byte[] Decode(ReadOnlySpan<char> input)
{
input = input.TrimEnd(Padding);
if (input.IsEmpty) return [];
Span<byte> output = stackalloc byte[5 * input.Length / 8];
var outputLength = 5 * input.Length / 8;
Span<byte> output = outputLength > MaxBytesStack
? new byte[outputLength]
: stackalloc byte[outputLength];
var size = Decode(input, output);
@@ -60,7 +144,25 @@ public static class Base32
public static int Decode(ReadOnlySpan<char> input, Span<byte> output)
{
input = input.TrimEnd(Padding);
Span<char> inputspan = stackalloc char[input.Length];
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)
{
throw new ArgumentException("Decoded input can not fit in output span.", nameof(output));
}
output = output[..outputLength];
output.Clear();
Span<char> inputspan = outputLength > MaxBytesStack
? new char[input.Length]
: stackalloc char[input.Length];
input.ToUpperInvariant(inputspan);
int bitIndex = 0;
@@ -100,16 +202,22 @@ public static class Base32
inputIndex++;
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
[Pure]
private static int GetNextGroup(ReadOnlySpan<byte> input, ref int offset, out byte a, out byte b, out byte c, out byte d, out byte e, out byte f, out byte g, out byte h)
private static int GetNextGroup(ReadOnlySpan<byte> input, ref int offset, Span<byte> alphabetKeys)
{
var retVal = (input.Length - offset) switch
{
@@ -119,20 +227,21 @@ public static class Base32
4 => 7,
_ => 8,
};
uint b1 = (offset < input.Length) ? input[offset++] : 0U;
uint b1 = input[offset++];
uint b2 = (offset < input.Length) ? input[offset++] : 0U;
uint b3 = (offset < input.Length) ? input[offset++] : 0U;
uint b4 = (offset < input.Length) ? input[offset++] : 0U;
uint b5 = (offset < input.Length) ? input[offset++] : 0U;
a = (byte)(b1 >> 3);
b = (byte)(((b1 & 0x07) << 2) | (b2 >> 6));
c = (byte)((b2 >> 1) & 0x1f);
d = (byte)(((b2 & 0x01) << 4) | (b3 >> 4));
e = (byte)(((b3 & 0x0f) << 1) | (b4 >> 7));
f = (byte)((b4 >> 2) & 0x1f);
g = (byte)(((b4 & 0x3) << 3) | (b5 >> 5));
h = (byte)(b5 & 0x1f);
alphabetKeys[0] = (byte)(b1 >> 3);
alphabetKeys[1] = (byte)(((b1 & 0x07) << 2) | (b2 >> 6));
alphabetKeys[2] = (byte)((b2 >> 1) & 0x1f);
alphabetKeys[3] = (byte)(((b2 & 0x01) << 4) | (b3 >> 4));
alphabetKeys[4] = (byte)(((b3 & 0x0f) << 1) | (b4 >> 7));
alphabetKeys[5] = (byte)((b4 >> 2) & 0x1f);
alphabetKeys[6] = (byte)(((b4 & 0x3) << 3) | (b5 >> 5));
alphabetKeys[7] = (byte)(b5 & 0x1f);
return retVal;
}
+86 -37
View File
@@ -1,30 +1,54 @@
using System.Runtime.InteropServices;
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
{
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)
{
ArgumentOutOfRangeException.ThrowIfNotEqual(value.Length, 11);
Span<byte> longBytes = stackalloc byte[8];
Span<char> chars = stackalloc char[12];
value.CopyTo(chars);
chars[^1] = Padding;
ReplaceNonUrlChars(chars);
if (!Convert.TryFromBase64Chars(chars, longBytes, out int _))
throw new FormatException("Invalid Base64 string.");
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)
{
ArgumentOutOfRangeException.ThrowIfNotEqual(value.Length, 22);
Span<byte> guidBytes = stackalloc byte[16];
Span<char> chars = stackalloc char[24];
value.CopyTo(chars);
for (int i = 0; i < value.Length; i++)
{
switch (value[i])
{
case '-': chars[i] = '+'; continue;
case '_': chars[i] = '/'; continue;
default: continue;
}
}
chars[^2..].Fill('=');
chars[^2..].Fill(Padding);
ReplaceNonUrlChars(chars);
if (!Convert.TryFromBase64Chars(chars, guidBytes, out int _))
throw new FormatException("Invalid Base64 string.");
return new Guid(guidBytes);
}
/// <summary>Decodes a Base64Url string into a byte array.</summary>
[Pure] public static byte[] Decode(ReadOnlySpan<char> input)
{
if (input.IsEmpty) return [];
@@ -36,25 +60,19 @@ public static class Base64Url
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)
{
var padding = (4 - (value.Length & 3)) & 3;
var charlen = value.Length + padding;
var outputBytes = charlen / 4;
var outputBytes = 3 * (charlen / 4);
ArgumentOutOfRangeException.ThrowIfLessThan(output.Length, outputBytes);
Span<char> chars = stackalloc char[charlen];
value.CopyTo(chars);
for (int i = 0; i < value.Length; i++)
{
switch (value[i])
{
case '-': chars[i] = '+'; continue;
case '_': chars[i] = '/'; continue;
default: continue;
}
}
chars[^padding..].Fill('=');
chars[^padding..].Fill(Padding);
ReplaceNonUrlChars(chars);
if (!Convert.TryFromBase64Chars(chars, output, out outputBytes))
throw new FormatException("Invalid Base64 string.");
@@ -62,6 +80,20 @@ public static class Base64Url
return outputBytes;
}
/// <summary>Encodes a <see cref="long"/> into an 11-character Base64Url string.</summary>
[Pure] public static string Encode(in long id)
{
Span<byte> longBytes = stackalloc byte[8];
MemoryMarshal.Write(longBytes, id);
Span<char> chars = stackalloc char[12];
Convert.TryToBase64Chars(longBytes, chars, out int _);
ReplaceUrlChars(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)
{
Span<byte> guidBytes = stackalloc byte[16];
@@ -69,52 +101,69 @@ public static class Base64Url
Span<char> chars = stackalloc char[24];
Convert.TryToBase64Chars(guidBytes, chars, out int _);
for (int i = 0; i < chars.Length - 2; i++)
{
switch (chars[i])
{
case '+': chars[i] = '-'; continue;
case '/': chars[i] = '_'; continue;
default: continue;
}
}
ReplaceUrlChars(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)
{
if (input.IsEmpty) return string.Empty;
int outLength = 8 * ((input.Length + 5) / 6);
int outLength = 4 * ((input.Length + 2) / 3);
Span<char> output = stackalloc char[outLength];
int strlen = Encode(input, output);
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)
{
if (input.IsEmpty) return 0;
var charlen = 8 * ((input.Length + 5) / 6);
var charlen = 4 * ((input.Length + 2) / 3);
Span<char> chars = stackalloc char[charlen];
Convert.TryToBase64Chars(input, chars, out int charsWritten);
int i;
for (i = 0; i < charsWritten; i++)
int i = ReplaceUrlChars(chars[..charsWritten]);
chars[..i].CopyTo(output);
return i;
}
private static int ReplaceUrlChars(Span<char> chars)
{
int i = 0;
for (; i < chars.Length; i++)
{
switch (chars[i])
{
case '+': chars[i] = '-'; continue;
case '/': chars[i] = '_'; continue;
case '=': goto exitLoop;
case Padding: goto break_loop;
default: continue;
}
}
exitLoop:
chars[..i].CopyTo(output);
break_loop:
return i;
}
private static int ReplaceNonUrlChars(Span<char> chars)
{
int i = 0;
for (; i < chars.Length; i++)
{
switch (chars[i])
{
case '-': chars[i] = '+'; continue;
case '_': chars[i] = '/'; continue;
case Padding: goto break_loop;
default: continue;
}
}
break_loop:
return i;
}
}
+9 -5
View File
@@ -1,9 +1,13 @@
using System.Numerics;
using System.Runtime.InteropServices;
using Just.Core.Extensions;
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
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>
{
byte[] headBytes = new byte[HeaderSize];
await stream.PopulateAsync(headBytes, cancellationToken);
await stream.ReadExactlyAsync(headBytes, cancellationToken);
var head = MemoryMarshal.Read<Header>(headBytes);
var bodySize = DataMap<T>.ElementSize * head.Width * head.Height;
if (bodySize != head.BodySize) throw new InvalidOperationException("Can not read DataMap. Element size mismatch.");
byte[] bodyBytes = new byte[bodySize];
await stream.PopulateAsync(bodyBytes, cancellationToken);
await stream.ReadExactlyAsync(bodyBytes, cancellationToken);
T[] body = MemoryMarshal.Cast<byte, T>(bodyBytes).ToArray();
return new DataMap<T>((int)head.Width, (int)head.Height, body);
@@ -157,7 +161,7 @@ public static class DataMap
{
Header head = default;
var headSpan = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref head, 1));
stream.Populate(headSpan);
stream.ReadExactly(headSpan);
var bodySize = DataMap<T>.ElementSize * head.Width * head.Height;
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];
var bodySpan = MemoryMarshal.AsBytes(body.AsSpan());
stream.Populate(bodySpan);
stream.ReadExactly(bodySpan);
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;
/// <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
where T : IComparisonOperators<T, T, bool>, IEqualityOperators<T, T, bool>
{
+2
View File
@@ -1,5 +1,7 @@
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>>
{
int Width { get; }
+122 -13
View File
@@ -3,31 +3,85 @@ using System.Collections.Immutable;
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> :
IEnumerable<T>,
IReadOnlyList<T>,
IEquatable<ImmutableSequence<T>>
{
private static readonly int InitialHash = typeof(ImmutableSequence<T>).GetHashCode();
private static readonly Func<T?, T?, bool> CompareItem = EqualityComparer<T>.Default.Equals;
private readonly ImmutableList<T> _values;
/// <summary>
/// Initializes a new empty instance of the <see cref="ImmutableSequence{T}"/> class.
/// </summary>
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() : this(ImmutableArray<T>.Empty)
{
}
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 enumerable sequence.
/// </summary>
/// <param name="values">The items to include in the sequence.</param>
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))
{
}
/// <summary>
/// Gets a value indicating whether the sequence contains any elements.
/// </summary>
public bool IsEmpty => _values.IsEmpty;
/// <summary>
/// Gets the number of elements in the sequence.
/// </summary>
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];
/// <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];
/// <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]
{
get
@@ -37,25 +91,60 @@ public class ImmutableSequence<T> :
}
}
protected virtual ImmutableSequence<T> ConstructNew(ImmutableList<T> values) => new(values);
/// <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];
public ImmutableSequence<T> Add(T value) => ConstructNew([.._values, value]);
public ImmutableSequence<T> AddFront(T value) => ConstructNew([value, .._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));
/// <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));
/// <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();
IEnumerator<T> IEnumerable<T>.GetEnumerator() => ((IEnumerable<T>)_values).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_values).GetEnumerator();
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)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
if (_values.Count != other?._values.Count)
if (_values.Count != other._values.Count)
{
return false;
}
@@ -71,11 +160,19 @@ public class ImmutableSequence<T> :
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>);
/// <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()
{
HashCode hash = new();
hash.Add(InitialHash);
foreach (var value in _values)
{
@@ -85,6 +182,18 @@ public class ImmutableSequence<T> :
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);
/// <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);
}
+7 -2
View File
@@ -2,6 +2,11 @@ using System.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>
{
internal readonly T[] _values;
@@ -68,8 +73,8 @@ public class Map<T> : IMap<T>
{
get
{
x = Math.Clamp(x, 0, Width);
y = Math.Clamp(y, 0, Height);
x = Math.Clamp(x, 0, Width - 1);
y = Math.Clamp(y, 0, Height - 1);
return ref _values[(y * Width) + x];
}
}
+2
View File
@@ -1,5 +1,7 @@
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 T Value = value;
+4 -2
View File
@@ -1,16 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Just.Core</AssemblyName>
<RootNamespace>Just.Core</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<Description>Small .Net library with useful helper classes, functions and extensions.</Description>
<PackageTags>extensions;helpers;helper-functions</PackageTags>
<Authors>JustFixMe</Authors>
<Copyright>Copyright (c) 2023,2024 JustFixMe</Copyright>
<Copyright>Copyright (c) 2023-2026 JustFixMe</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/JustFixMe/Just.Core/</RepositoryUrl>
+90 -13
View File
@@ -1,44 +1,121 @@
namespace Just.Core.Extensions;
/// <summary>
/// Provides extension methods for <see cref="Stream"/> to fully populate buffers.
/// </summary>
[Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")]
public static class SystemIOStreamExtensions
{
/// <summary>
/// Reads data from the stream until the specified section of the buffer is filled.
/// </summary>
/// <param name="stream">The stream to read from</param>
/// <param name="buffer">The buffer to populate</param>
/// <param name="offset">The starting offset in the buffer</param>
/// <param name="length">The number of bytes to read</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="stream"/> is null</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="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)
=> stream.Populate(buffer.AsSpan(offset, length));
/// <summary>
/// Reads data from the stream until the entire buffer is filled.
/// </summary>
/// <param name="stream">The stream to read from</param>
/// <param name="buffer">The buffer to populate</param>
/// <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="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)
=> stream.Populate(buffer.AsSpan());
/// <summary>
/// Reads data from the stream until the specified span is filled.
/// </summary>
/// <param name="stream">The stream to read from</param>
/// <param name="buffer">The span to populate</param>
/// <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="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)
{
while (buffer.Length > 0)
{
var readed = stream.Read(buffer);
ArgumentNullException.ThrowIfNull(stream);
if (readed == 0)
while (!buffer.IsEmpty)
{
var bytesRead = stream.Read(buffer);
if (bytesRead == 0)
{
throw new EndOfStreamException();
}
buffer = buffer[readed..];
buffer = buffer[bytesRead..];
}
}
public static async ValueTask PopulateAsync(this Stream stream, byte[] buffer, CancellationToken cancellationToken = default)
=> await stream.PopulateAsync(buffer.AsMemory(), cancellationToken);
public static async ValueTask PopulateAsync(this Stream stream, byte[] buffer, int offset, int length, CancellationToken cancellationToken = default)
=> await stream.PopulateAsync(buffer.AsMemory(offset, length), cancellationToken);
/// <summary>
/// Asynchronously reads data from the stream until the entire buffer is filled.
/// </summary>
/// <param name="stream">The stream to read from</param>
/// <param name="buffer">The buffer to populate</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>A ValueTask representing the asynchronous operation</returns>
/// <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="OperationCanceledException">Thrown if canceled via cancellation token</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)
=> stream.PopulateAsync(buffer.AsMemory(), cancellationToken);
/// <summary>
/// Asynchronously reads data from the stream until the specified section of the buffer is filled.
/// </summary>
/// <param name="stream">The stream to read from</param>
/// <param name="buffer">The buffer to populate</param>
/// <param name="offset">The starting offset in the buffer</param>
/// <param name="length">The number of bytes to read</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>A ValueTask representing the asynchronous operation</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="stream"/> is null</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="OperationCanceledException">Thrown if canceled via cancellation token</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)
=> stream.PopulateAsync(buffer.AsMemory(offset, length), cancellationToken);
/// <summary>
/// Asynchronously reads data from the stream until the specified memory region is filled.
/// </summary>
/// <param name="stream">The stream to read from</param>
/// <param name="buffer">The memory region to populate</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>A ValueTask representing the asynchronous operation</returns>
/// <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="OperationCanceledException">Thrown if canceled via cancellation token</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)
{
while (buffer.Length > 0)
ArgumentNullException.ThrowIfNull(stream);
while (!buffer.IsEmpty)
{
cancellationToken.ThrowIfCancellationRequested();
var readed = await stream.ReadAsync(buffer, cancellationToken);
var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (readed == 0)
if (bytesRead == 0)
{
throw new EndOfStreamException();
}
buffer = buffer[readed..];
buffer = buffer[bytesRead..];
}
}
}
+63 -20
View File
@@ -3,38 +3,81 @@ using System.Security.Cryptography;
namespace Just.Core;
public enum GuidV8Entropy { Strong, Weak }
public static class GuidV8
{
[Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Guid NewGuid(GuidV8Entropy entropy = GuidV8Entropy.Strong) => NewGuid(DateTime.UtcNow, entropy);
private const long TicksPrecision = TimeSpan.TicksPerMillisecond / 10; // 100-microsecond units
[Pure]
public static Guid NewGuid(DateTime dateTime, GuidV8Entropy entropy = GuidV8Entropy.Strong)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ExcludeFromCodeCoverage]
public static Guid NewGuid(RngEntropy entropy = RngEntropy.Strong) => NewGuid(DateTime.UtcNow, entropy);
public static Guid NewGuid(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong)
{
var epoch = dateTime.Subtract(DateTime.UnixEpoch);
var timestamp = epoch.Ticks / (TimeSpan.TicksPerMillisecond / 10);
var timestamp = epoch.Ticks / TicksPrecision;
Span<byte> ts = stackalloc byte[8];
MemoryMarshal.Write(ts, timestamp);
Span<byte> bytes = stackalloc byte[16];
ts[0..2].CopyTo(bytes[4..6]);
ts[2..6].CopyTo(bytes[..4]);
if (entropy == GuidV8Entropy.Strong)
// Negative timestamps can't be encoded correctly due to unsigned bit operations
if (timestamp < 0)
{
RandomNumberGenerator.Fill(bytes[6..]);
throw new ArgumentException("Timestamp must be on or after UnixEpoch (1970-01-01 UTC).", nameof(dateTime));
}
uint tsHigh = (uint)((timestamp >> 16) & 0xFFFFFFFF);
ushort tsLow = (ushort)(timestamp & 0x0000FFFF);
Span<byte> bytes = stackalloc byte[10];
if (entropy == RngEntropy.Strong)
{
RandomNumberGenerator.Fill(bytes);
}
else
{
Random.Shared.NextBytes(bytes[6..]);
Random.Shared.NextBytes(bytes);
}
bytes[7] = (byte)((bytes[7] & 0x0F) | 0x80);
bytes[0] = (byte)((bytes[0] & 0x0F) | 0x80); // Version 8
bytes[2] = (byte)((bytes[2] & 0x1F) | 0x80); // Variant 0b1000
return new Guid(bytes);
ushort version = (ushort)((bytes[0] << 8) | bytes[1]);
return new Guid(
tsHigh,
tsLow,
version,
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);
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace Just.Core;
/// <summary>
/// Specifies the quality of random entropy used in ID generation
/// </summary>
public enum RngEntropy
{
/// <summary>
/// Cryptographically secure random numbers (slower but collision-resistant)
/// </summary>
Strong,
/// <summary>
/// Standard pseudo-random numbers (faster but less collision-resistant)
/// </summary>
Weak
}
+124
View File
@@ -0,0 +1,124 @@
using System.Security.Cryptography;
namespace Just.Core;
/// <summary>
/// Generates time-based sequential IDs with entropy
/// <para>ID Structure (64 bits):</para>
/// <list type="bullet">
/// <item><description>[1 bit] Always 0 (positive signed longs)</description></item>
/// <item><description>[41 bits] Milliseconds since epoch (covers ~69 years)</description></item>
/// <item><description>[8 bits] Sequence counter (0-255 per millisecond)</description></item>
/// <item><description>[14 bits] Random entropy (0-16383)</description></item>
/// </list>
/// </summary>
/// <remarks>
/// Important behaviors:
/// <list type="bullet">
/// <item><description>Guarantees monotonic ordering within same millisecond</description></item>
/// <item><description>Throws <see cref="InvalidOperationException"/> on backward time jumps</description></item>
/// <item><description>Sequence resets when timestamp advances</description></item>
/// <item><description>Thread-safe through locking</description></item>
/// </list>
/// </remarks>
public sealed class SeqId(DateTime epoch)
{
private const int TimestampBits = 41;
private const int TimestampShift = 63 - TimestampBits;
private const long TimestampMask = 0x000001FF_FFFFFFFF;
private const int SeqBits = 8;
private const int SeqShift = TimestampShift - SeqBits;
private const long SeqMask = 0x00000000_000000FF;
private const int RandExclusiveUpper = 1 << SeqShift;
/// <summary>
/// Default epoch (2025-01-01 UTC) used for <see cref="Default"/> instance
/// </summary>
public static DateTime DefaultEpoch { get; } = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>
/// Default instance using <see cref="DefaultEpoch"/>
/// </summary>
public static SeqId Default { get; } = new(DefaultEpoch);
/// <summary>
/// Generates ID using default instance and current UTC time
/// </summary>
/// <param name="entropy">Entropy quality (default: Strong)</param>
/// <returns>64-bit sequential ID with random component</returns>
/// <exception cref="IndexOutOfRangeException">
/// Thrown if more than 256 IDs generated in 1ms
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long NextId(RngEntropy entropy = RngEntropy.Strong) => Default.Next(entropy);
#if NET9_0_OR_GREATER
private readonly Lock _lock = new();
#else
private readonly object _lock = new();
#endif
private readonly DateTime _epoch = epoch;
private int _seqId = 0;
private long _lastTimestamp = -1L;
private Func<DateTime> _defaultTimeFactory = static () => DateTime.UtcNow;
/// <summary>
/// Generates next ID using current UTC time
/// </summary>
/// <param name="entropy">Entropy quality (default: Strong)</param>
/// <returns>64-bit sequential ID with random component</returns>
/// <exception cref="IndexOutOfRangeException">
/// Thrown if more than 256 IDs generated in 1ms
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Next(RngEntropy entropy = RngEntropy.Strong) => Next(_defaultTimeFactory(), entropy);
/// <summary>
/// Generates next ID with explicit timestamp
/// </summary>
/// <param name="dateTime">Timestamp basis for ID generation</param>
/// <param name="entropy">Entropy quality (default: Strong)</param>
/// <returns>64-bit sequential ID with random component</returns>
/// <exception cref="InvalidOperationException">
/// Thrown if <paramref name="dateTime"/> is earlier than last used timestamp
/// </exception>
/// <exception cref="IndexOutOfRangeException">
/// Thrown if more than 256 IDs generated in 1ms
/// </exception>
public long Next(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong)
{
var epoch = dateTime.Subtract(_epoch);
var timestamp = ((epoch.Ticks / TimeSpan.TicksPerMillisecond) & TimestampMask) << TimestampShift;
long currentSeq;
lock (_lock)
{
if (timestamp > _lastTimestamp)
{
_lastTimestamp = timestamp;
_seqId = 0;
}
else if (timestamp < _lastTimestamp)
{
throw new InvalidOperationException("Refused to create new SeqId. Last timestamp is in the future.");
}
if (_seqId > SeqMask)
{
throw new IndexOutOfRangeException("Refused to create new SeqId. Sequence exhausted.");
}
currentSeq = ((_seqId++) & SeqMask) << SeqShift;
}
long currentRand = entropy == RngEntropy.Strong
? RandomNumberGenerator.GetInt32(RandExclusiveUpper)
: Random.Shared.Next(RandExclusiveUpper);
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,2024 JustFixMe
Copyright (c) 2023-2026 JustFixMe
Permission is hereby granted, free of charge, to any person obtaining a copy
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
- ```Map``` and ```DataMap``` collection types
- Extensions for ```System.String``` and ```System.IO.Stream```
- Small converters for **Base32** and **Base64Url** encodings
### Encoding
## Getting Started
**Base32** — RFC 4648 encoder/decoder with lowercase and no-padding options.
### Install from NuGet.org
```sh
# install the package using NuGet
dotnet add package Just.Core
```csharp
var encoded = Base32.Encode(bytes); // "ABCDEFGH..."
var encoded = Base32.Encode(bytes, Base32EncodeOptions.LowerCaseNoPadding);
var bytes = Base32.Decode("ABCDEFGH...");
```
**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).