diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml
new file mode 100644
index 00000000..5fbbae8e
--- /dev/null
+++ b/.github/workflows/unit-testing.yml
@@ -0,0 +1,41 @@
+name: Unit testing (Windows / MSBuild)
+
+on:
+ workflow_dispatch:
+ push:
+ branches: ["master"]
+ pull_request:
+ branches: ["master"]
+ schedule:
+ - cron: "0 0 * * 0" # weekly, Sunday 00:00 UTC
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: windows-latest
+
+ env:
+ SOLUTION_NAME: TechnitiumLibrary.sln
+ BUILD_CONFIGURATION: Debug
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install .NET 9 SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Add MSBuild to PATH
+ uses: microsoft/setup-msbuild@v2
+
+ - name: Restore
+ run: msbuild ${{ env.SOLUTION_NAME }} /t:Restore
+
+ - name: Build
+ run: msbuild ${{ env.SOLUTION_NAME }} /m /p:Configuration=${{ env.BUILD_CONFIGURATION }}
+
+ - name: Test (msbuild)
+ run: msbuild TechnitiumLibrary.UnitTests\TechnitiumLibrary.UnitTests.csproj /t:Test /p:Configuration=${{ env.BUILD_CONFIGURATION }}
\ No newline at end of file
diff --git a/README.md b/README.md
index b329873a..ad146a6c 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,6 @@
# TechnitiumLibrary
A library for .net based applications.
+
+## Quality Assurance
+
+[](https://github.com/TechnitiumSoftware/TechnitiumLibrary/actions/workflows/unit-testing.yml)
\ No newline at end of file
diff --git a/TechnitiumLibrary.UnitTests/MSTestSettings.cs b/TechnitiumLibrary.UnitTests/MSTestSettings.cs
new file mode 100644
index 00000000..e466aa12
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/MSTestSettings.cs
@@ -0,0 +1,3 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/BinaryReaderExtensionsTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/BinaryReaderExtensionsTests.cs
new file mode 100644
index 00000000..e74b15a0
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/BinaryReaderExtensionsTests.cs
@@ -0,0 +1,166 @@
+/*
+Technitium Library
+Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
+Copyright (C) 2026 Zafer Balkan (zafer@zaferbalkan.com)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using TechnitiumLibrary.IO;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.IO
+{
+ [TestClass]
+ public sealed class BinaryReaderExtensionsTests
+ {
+ private static BinaryReader ReaderOf(params byte[] bytes)
+ {
+ return new BinaryReader(new MemoryStream(bytes));
+ }
+
+ // -----------------------------------------------
+ // ReadLength()
+ // -----------------------------------------------
+
+ [TestMethod]
+ public void ReadLength_ShouldReadSingleByteLengths()
+ {
+ // GIVEN
+ BinaryReader reader = ReaderOf(0x05);
+
+ // WHEN
+ int length = reader.ReadLength();
+
+ // THEN
+ Assert.AreEqual(5, length);
+ Assert.AreEqual(1, reader.BaseStream.Position);
+ }
+
+ [TestMethod]
+ public void ReadLength_ShouldReadMultiByteBigEndianLengths()
+ {
+ // GIVEN
+ // 0x82 => 2-byte length follows → value = 0x01 0x2C → 300 decimal
+ BinaryReader reader = ReaderOf(0x82, 0x01, 0x2C);
+
+ // WHEN
+ int length = reader.ReadLength();
+
+ // THEN
+ Assert.AreEqual(300, length);
+ Assert.AreEqual(3, reader.BaseStream.Position);
+ }
+
+ [TestMethod]
+ public void ReadLength_ShouldThrow_WhenLengthPrefixTooLarge()
+ {
+ // GIVEN
+ // lower 7 bits = 0x05, meaning "next 5 bytes", exceeding allowed 4
+ BinaryReader reader = ReaderOf(0x85);
+
+ // WHEN-THEN
+ Assert.ThrowsExactly(() => reader.ReadLength());
+ }
+
+ // -----------------------------------------------
+ // ReadBuffer()
+ // -----------------------------------------------
+
+ [TestMethod]
+ public void ReadBuffer_ShouldReturnBytes_WhenLengthPrefixed()
+ {
+ // GIVEN
+ // length=3, then bytes 0xAA, 0xBB, 0xCC
+ BinaryReader reader = ReaderOf(0x03, 0xAA, 0xBB, 0xCC);
+
+ // WHEN
+ byte[] data = reader.ReadBuffer();
+
+ // THEN
+ Assert.HasCount(3, data);
+ CollectionAssert.AreEqual(new byte[] { 0xAA, 0xBB, 0xCC }, data);
+ }
+
+ // -----------------------------------------------
+ // ReadShortString()
+ // -----------------------------------------------
+
+ [TestMethod]
+ public void ReadShortString_ShouldDecodeUtf8StringCorrectly()
+ {
+ // GIVEN
+ string text = "Hello";
+ byte[] encoded = Encoding.UTF8.GetBytes(text);
+
+ byte[] bytes = new byte[] { (byte)encoded.Length }.Concat(encoded).ToArray();
+ BinaryReader reader = ReaderOf(bytes);
+
+ // WHEN
+ string result = reader.ReadShortString();
+
+ // THEN
+ Assert.AreEqual(text, result);
+ }
+
+ [TestMethod]
+ public void ReadShortString_ShouldUseSpecifiedEncoding()
+ {
+ // GIVEN
+ string text = "Å";
+ Encoding encoding = Encoding.UTF32;
+ byte[] encoded = encoding.GetBytes(text);
+
+ byte[] bytes = new byte[] { (byte)encoded.Length }.Concat(encoded).ToArray();
+ BinaryReader reader = ReaderOf(bytes);
+
+ // WHEN
+ string result = reader.ReadShortString(encoding);
+
+ // THEN
+ Assert.AreEqual(text, result);
+ }
+
+ // -----------------------------------------------
+ // ReadDateTime()
+ // -----------------------------------------------
+
+ [TestMethod]
+ public void ReadDateTime_ShouldConvertEpochMilliseconds()
+ {
+ // GIVEN
+ DateTime expected = new DateTime(2024, 01, 01, 12, 00, 00, DateTimeKind.Utc);
+ long millis = (long)(expected - DateTime.UnixEpoch).TotalMilliseconds;
+
+ byte[] encoded = BitConverter.GetBytes(millis);
+
+ // Normalize to little-endian, which BinaryReader expects
+ if (!BitConverter.IsLittleEndian)
+ Array.Reverse(encoded);
+
+ BinaryReader reader = ReaderOf(encoded);
+
+ // WHEN
+ DateTime result = reader.ReadDateTime();
+
+ // THEN
+ Assert.AreEqual(expected, result);
+ }
+ }
+}
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs
new file mode 100644
index 00000000..d26c8ebb
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs
@@ -0,0 +1,228 @@
+/*
+Technitium Library
+Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
+Copyright (C) 2026 Zafer Balkan (zafer@zaferbalkan.com)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using TechnitiumLibrary.IO;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.IO
+{
+ [TestClass]
+ public sealed class BinaryWriterExtensionsTests
+ {
+ // ---------------------------------------
+ // WriteLength() tests
+ // ---------------------------------------
+
+ [TestMethod]
+ public void WriteLength_ShouldEncodeSingleByte_WhenLessThan128()
+ {
+ // GIVEN
+ using MemoryStream ms = new MemoryStream();
+ using BinaryWriter bw = new BinaryWriter(ms);
+
+ // WHEN
+ bw.WriteLength(42);
+
+ // THEN
+
+ CollectionAssert.AreEqual(new byte[] { 42 }, ms.ToArray());
+ }
+
+ [TestMethod]
+ public void WriteLength_ShouldEncodeMultiByte_BigEndianForm()
+ {
+ // GIVEN
+ using MemoryStream ms = new MemoryStream();
+ using BinaryWriter bw = new BinaryWriter(ms);
+
+ // WHEN
+ // length = 0x0000012C (300 decimal)
+ bw.WriteLength(300);
+
+ // THEN
+ // Prefix = 0x82 (2 bytes follow)
+ // Then big-endian 01 2C
+ try
+ {
+ CollectionAssert.AreEqual(
+ new byte[] { 0x82, 0x01, 0x2C },
+ ms.ToArray()
+ );
+ }
+ finally
+ {
+ ms.Dispose();
+ }
+ }
+
+ // ---------------------------------------
+ // WriteBuffer()
+ // ---------------------------------------
+
+ [TestMethod]
+ public void WriteBuffer_ShouldPrefixLength_AndWriteBytes()
+ {
+ // GIVEN
+ using MemoryStream ms = new MemoryStream();
+ using BinaryWriter bw = new BinaryWriter(ms);
+ byte[] data = new byte[] { 0xAA, 0xBB, 0xCC };
+
+ // WHEN
+ bw.WriteBuffer(data);
+
+ // THEN
+ try
+ {
+ CollectionAssert.AreEqual(
+ new byte[] { 0x03, 0xAA, 0xBB, 0xCC },
+ ms.ToArray()
+ );
+ }
+ finally
+ {
+ ms.Dispose();
+ }
+ }
+
+ [TestMethod]
+ public void WriteBuffer_WithOffset_ShouldWriteExpectedSegment()
+ {
+ // GIVEN
+ using MemoryStream ms = new MemoryStream();
+ using BinaryWriter bw = new BinaryWriter(ms);
+ byte[] data = new byte[] { 1, 2, 3, 4, 5 };
+
+ // WHEN
+ bw.WriteBuffer(data, offset: 1, count: 3);
+
+ // THEN
+ try
+ {
+ CollectionAssert.AreEqual(
+ new byte[] { 0x03, 2, 3, 4 },
+ ms.ToArray()
+ );
+ }
+ finally
+ {
+ ms.Dispose();
+ }
+ }
+
+ // ---------------------------------------
+ // WriteShortString()
+ // ---------------------------------------
+
+ [TestMethod]
+ public void WriteShortString_ShouldWriteUtf8EncodedWithLength()
+ {
+ // GIVEN
+ using MemoryStream ms = new MemoryStream();
+ using BinaryWriter bw = new BinaryWriter(ms);
+ string text = "Hello";
+ byte[] utf8 = Encoding.UTF8.GetBytes(text);
+
+ // WHEN
+ bw.WriteShortString(text);
+
+ // THEN
+ byte[] expected = new byte[] { (byte)utf8.Length }
+ .Concat(utf8)
+ .ToArray();
+
+ try
+ {
+ CollectionAssert.AreEqual(expected, ms.ToArray());
+ }
+ finally
+ {
+ ms.Dispose();
+ }
+ }
+
+ [TestMethod]
+ public void WriteShortString_ShouldUseSpecifiedEncoding()
+ {
+ // GIVEN
+ using MemoryStream ms = new MemoryStream();
+ using BinaryWriter bw = new BinaryWriter(ms);
+ string text = "Å";
+ Encoding enc = Encoding.UTF32;
+ byte[] bytes = enc.GetBytes(text);
+
+ // WHEN
+ bw.WriteShortString(text, enc);
+
+ // THEN
+ byte[] expected = new byte[] { (byte)bytes.Length }
+ .Concat(bytes)
+ .ToArray();
+
+ try
+ {
+ CollectionAssert.AreEqual(expected, ms.ToArray());
+ }
+ finally
+ {
+ ms.Dispose();
+ }
+ }
+
+ [TestMethod]
+ public void WriteShortString_ShouldThrow_WhenStringTooLong()
+ {
+ // GIVEN
+ using MemoryStream ms = new MemoryStream();
+ using BinaryWriter bw = new BinaryWriter(ms);
+ string input = new string('x', 256); // UTF-8 => 256 bytes
+
+ // WHEN–THEN
+ Assert.ThrowsExactly(() =>
+ bw.WriteShortString(input)
+ );
+ }
+
+ // ---------------------------------------
+ // Write(DateTime)
+ // ---------------------------------------
+
+ [TestMethod]
+ public void WriteDate_ShouldEncodeMillisecondsFromUnixEpoch()
+ {
+ // GIVEN
+ DateTime expected = new DateTime(2024, 1, 2, 12, 00, 00, DateTimeKind.Utc);
+ long millis = (long)(expected - DateTime.UnixEpoch).TotalMilliseconds;
+
+ byte[] bytes = BitConverter.GetBytes(millis);
+ using MemoryStream ms = new MemoryStream();
+ using BinaryWriter bw = new BinaryWriter(ms);
+
+ // WHEN
+ bw.Write(expected);
+
+ // THEN
+ CollectionAssert.AreEqual(bytes, ms.ToArray());
+ }
+ }
+}
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/JointTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/JointTests.cs
new file mode 100644
index 00000000..3abfb3b0
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/JointTests.cs
@@ -0,0 +1,206 @@
+/*
+Technitium Library
+Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
+Copyright (C) 2026 Zafer Balkan (zafer@zaferbalkan.com)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using TechnitiumLibrary.IO;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.IO
+{
+ [TestClass]
+ public sealed class JointTests
+ {
+ private static async Task WaitForCopyCompletion()
+ {
+ // The copy tasks run asynchronously and Joint.Dispose() executes
+ // when either side reaches EOF. Wait slightly longer than default buffering time.
+ await Task.Delay(80);
+ }
+
+ // ---------------------------------------
+ // Constructor and property access
+ // ---------------------------------------
+
+ [TestMethod]
+ public void Constructor_ShouldStoreStreams()
+ {
+ // GIVEN
+ MemoryStream s1 = new MemoryStream();
+ MemoryStream s2 = new MemoryStream();
+
+ // WHEN
+ Joint joint = new Joint(s1, s2);
+
+ // THEN
+ Assert.AreSame(s1, joint.Stream1);
+ Assert.AreSame(s2, joint.Stream2);
+ }
+
+ // ---------------------------------------
+ // Data transfer behavior
+ // ---------------------------------------
+
+ [TestMethod]
+ public async Task Start_ShouldCopyData_FromStream1ToStream2()
+ {
+ // GIVEN
+ byte[] sourceData = new byte[] { 1, 2, 3, 4 };
+ using MemoryStream s1 = new MemoryStream(sourceData);
+ using MemoryStream s2 = new MemoryStream();
+ using Joint joint = new Joint(s1, s2);
+
+ // WHEN
+ joint.Start();
+ await WaitForCopyCompletion();
+
+ // THEN
+ byte[] result = s2.ToArray();
+ CollectionAssert.AreEqual(sourceData, result);
+ }
+
+ [TestMethod]
+ public async Task Start_ShouldCopyData_FromStream2ToStream1()
+ {
+ // GIVEN
+ byte[] sourceData = new byte[] { 7, 8, 9 };
+ using MemoryStream s1 = new MemoryStream();
+ using MemoryStream s2 = new MemoryStream(sourceData);
+ using Joint joint = new Joint(s1, s2);
+
+ // WHEN
+ joint.Start();
+ await WaitForCopyCompletion();
+
+ // THEN
+ byte[] result = s2.ToArray();
+ CollectionAssert.AreEqual(sourceData, result);
+ }
+
+ // ---------------------------------------
+ // Empty stream scenarios
+ // ---------------------------------------
+
+ [TestMethod]
+ public async Task Start_ShouldSupportEmptyStreams()
+ {
+ // GIVEN
+ using MemoryStream s1 = new MemoryStream();
+ using MemoryStream s2 = new MemoryStream();
+ using Joint joint = new Joint(s1, s2);
+
+ // WHEN
+ joint.Start();
+ await WaitForCopyCompletion();
+
+ // THEN
+ byte[] buff1 = s1.ToArray();
+ byte[] buff2 = s2.ToArray();
+
+ CollectionAssert.AreEqual(Array.Empty(), buff1);
+ CollectionAssert.AreEqual(Array.Empty(), buff2);
+ }
+
+ // ---------------------------------------
+ // Disposal semantics
+ // ---------------------------------------
+
+ [TestMethod]
+ public async Task Dispose_ShouldCloseStreams()
+ {
+ // GIVEN
+ MemoryStream s1 = new MemoryStream(new byte[] { 10 });
+ MemoryStream s2 = new MemoryStream(new byte[] { 20 });
+ Joint joint = new Joint(s1, s2);
+
+ // WHEN
+ joint.Dispose();
+ await WaitForCopyCompletion();
+
+ // THEN
+ Assert.ThrowsExactly(() => { _ = s1.Length; });
+ Assert.ThrowsExactly(() => { _ = s2.Length; });
+ }
+
+ [TestMethod]
+ public void Dispose_ShouldBeIdempotent()
+ {
+ // GIVEN
+ MemoryStream s1 = new MemoryStream();
+ MemoryStream s2 = new MemoryStream();
+ Joint joint = new Joint(s1, s2);
+
+ // WHEN
+ joint.Dispose();
+ joint.Dispose();
+ joint.Dispose(); // Should not throw
+
+ // THEN
+ Assert.IsTrue(true); // No exception was thrown
+ }
+
+ // ---------------------------------------
+ // Disposal callback behavior
+ // ---------------------------------------
+
+ [TestMethod]
+ public void Dispose_ShouldRaiseDisposingEvent()
+ {
+ // GIVEN
+ using MemoryStream s1 = new MemoryStream();
+ using MemoryStream s2 = new MemoryStream();
+ Joint joint = new Joint(s1, s2);
+
+ bool raised = false;
+ joint.Disposing += (_, __) => raised = true;
+
+ // WHEN
+ joint.Dispose();
+
+ // THEN
+ Assert.IsTrue(raised);
+ }
+
+ // ---------------------------------------
+ // Concurrency semantics
+ // ---------------------------------------
+
+ [TestMethod]
+ public async Task Start_ShouldDisposeOnce_WhenBothDirectionsComplete()
+ {
+ // GIVEN
+ using MemoryStream s1 = new MemoryStream(new byte[] { 1 });
+ using MemoryStream s2 = new MemoryStream(new byte[] { 2 });
+
+ using Joint joint = new Joint(s1, s2);
+
+ int disposedCount = 0;
+ joint.Disposing += (_, __) => disposedCount++;
+
+ // WHEN
+ joint.Start();
+ await WaitForCopyCompletion();
+
+ // THEN
+ Assert.AreEqual(1, disposedCount, "Disposing must fire only once");
+ }
+ }
+}
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/OffsetStreamTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/OffsetStreamTests.cs
new file mode 100644
index 00000000..4064485e
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/OffsetStreamTests.cs
@@ -0,0 +1,264 @@
+/*
+Technitium Library
+Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
+Copyright (C) 2026 Zafer Balkan (zafer@zaferbalkan.com)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using TechnitiumLibrary.IO;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.IO
+{
+ [TestClass]
+ public sealed class OffsetStreamTests
+ {
+ public TestContext TestContext { get; set; } = null!;
+
+
+ // ------------------------------------------------------
+ // CONSTRUCTION & BASIC METADATA
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Constructor_ShouldExposeCorrectBasicProperties()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }, writable: true);
+
+ // WHEN
+ using OffsetStream offsetStream = new OffsetStream(source, offset: 1, length: 3);
+
+ // THEN
+ Assert.AreEqual(3, offsetStream.Length);
+ Assert.AreEqual(0, offsetStream.Position);
+ Assert.IsTrue(offsetStream.CanRead);
+ Assert.IsTrue(offsetStream.CanSeek);
+ }
+
+ [TestMethod]
+ public void Constructor_ShouldRespectReadOnlyFlag()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[10], writable: true);
+
+ // WHEN
+ using OffsetStream offsetStream = new OffsetStream(source, readOnly: true);
+
+ // THEN
+ Assert.IsFalse(offsetStream.CanWrite);
+ }
+
+ // ------------------------------------------------------
+ // READ OPERATIONS
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Read_ShouldReturnSegmentWithinBounds()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 10, 20, 30, 40, 50 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, offset: 1, length: 3);
+
+ byte[] buffer = new byte[10];
+
+ // WHEN
+ int readCount = offsetStream.Read(buffer, 0, 10);
+
+ // THEN
+ Assert.AreEqual(3, readCount);
+ CollectionAssert.AreEqual(new byte[] { 20, 30, 40 }, buffer[..3]);
+ }
+
+ [TestMethod]
+ public void Read_ShouldReturnZero_WhenPastLength()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 1, 2, 3, 4 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, offset: 2, length: 1);
+
+ byte[] buffer = new byte[5];
+ offsetStream.Position = 1;
+
+ // WHEN
+ int count = offsetStream.Read(buffer, 0, 5);
+
+ // THEN
+ Assert.AreEqual(0, count);
+ }
+
+ [TestMethod]
+ public void ReadAsync_ShouldReturnCorrectData()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 9, 8, 7, 6 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, offset: 1, length: 2);
+ byte[] buffer = new byte[10];
+
+ // WHEN
+ int count = offsetStream.ReadAsync(buffer, 0, 10, TestContext.CancellationToken).Result;
+
+ // THEN
+ Assert.AreEqual(2, count);
+ CollectionAssert.AreEqual(new byte[] { 8, 7 }, buffer[..2]);
+ }
+
+ // ------------------------------------------------------
+ // WRITE OPERATIONS
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Write_ShouldPlaceDataAtOffset()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 1, 2, 3, 4 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, offset: 1, length: 2);
+
+ // WHEN
+ offsetStream.Write("23"u8.ToArray(), 0, 2);
+
+ // THEN
+ CollectionAssert.AreEqual(new byte[] { 1, 50, 51, 4 }, source.ToArray());
+ }
+
+ [TestMethod]
+ public void Write_ShouldExtendLength()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 1, 2, 3 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, offset: 0, length: 2);
+
+ // WHEN
+ offsetStream.Position = 2;
+ offsetStream.Write("\t"u8.ToArray(), 0, 1);
+
+ // THEN
+ Assert.AreEqual(3, offsetStream.Length);
+ }
+
+ [TestMethod]
+ public void Write_ShouldThrow_WhenReadOnly()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 1, 2, 3 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, readOnly: true);
+
+ // WHEN–THEN
+ Assert.ThrowsExactly(() =>
+ offsetStream.Write(new byte[] { 0 }, 0, 1));
+ }
+
+ // ------------------------------------------------------
+ // SEEK OPERATIONS
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Seek_ShouldMoveWithinValidRange()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 1, 2, 3, 4 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, offset: 0, length: 4);
+
+ // WHEN
+ long newPos = offsetStream.Seek(2, SeekOrigin.Begin);
+
+ // THEN
+ Assert.AreEqual(2, newPos);
+ Assert.AreEqual(2, offsetStream.Position);
+ }
+
+ [TestMethod]
+ public void Seek_ShouldThrow_WhenSeekingPastEnd()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 1, 2, 3 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, offset: 0, length: 3);
+
+ // WHEN–THEN
+ Assert.ThrowsExactly(() =>
+ offsetStream.Seek(4, SeekOrigin.Begin));
+ }
+
+ // ------------------------------------------------------
+ // DISPOSAL OWNERSHIP
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Dispose_ShouldCloseBaseStream_WhenOwnsStream()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 1 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, ownsStream: true);
+
+ // WHEN
+ offsetStream.Dispose();
+
+ // THEN
+ Assert.ThrowsExactly(() => source.ReadByte());
+ }
+
+ [TestMethod]
+ public void Dispose_ShouldNotCloseBaseStream_WhenNotOwned()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 1 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, ownsStream: false);
+
+ // WHEN
+ offsetStream.Dispose();
+
+ // THEN
+ Assert.AreEqual(1, source.ReadByte());
+ }
+
+ // ------------------------------------------------------
+ // WRITETO & WRITETOASYNC
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void WriteTo_ShouldCopyOnlyOffsetRange()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream(new byte[] { 10, 20, 30, 40 }, writable: true);
+ using OffsetStream offsetStream = new OffsetStream(source, offset: 1, length: 2);
+ using MemoryStream target = new MemoryStream();
+
+ // WHEN
+ offsetStream.WriteTo(target);
+
+ // THEN
+ CollectionAssert.AreEqual(new byte[] { 20, 30 }, target.ToArray());
+ }
+
+ [TestMethod]
+ public async Task WriteToAsync_ShouldCopyOnlyOffsetRange()
+ {
+ // GIVEN
+ using MemoryStream source = new MemoryStream("2.
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.IO;
+using TechnitiumLibrary.IO;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.IO
+{
+ [TestClass]
+ public sealed class PackageItemTests
+ {
+ private static MemoryStream StreamOf(params byte[] bytes) =>
+ new MemoryStream(bytes, writable: true);
+
+ private static PackageItem CreateMinimalWritable()
+ {
+ MemoryStream ms = StreamOf(1, 2, 3);
+ return new PackageItem("file.bin", ms);
+ }
+
+ // ---------------------------------------------------------
+ // CONSTRUCTION
+ // ---------------------------------------------------------
+
+ [TestMethod]
+ public void Constructor_ShouldCreateItemFromStream()
+ {
+ using MemoryStream ms = StreamOf(10, 20, 30);
+ using PackageItem item = new PackageItem("abc.txt", ms);
+
+ Assert.AreEqual("abc.txt", item.Name);
+ Assert.IsFalse(item.IsAttributeSet(PackageItemAttributes.ExecuteFile));
+ Assert.AreEqual(ms, item.DataStream);
+ }
+
+ [TestMethod]
+ public void Constructor_FromFilePath_ShouldCaptureAttributesAndOwnStream()
+ {
+ // Create an isolated private subfolder under temp,
+ // because direct writes to global temp root are unsafe.
+ string secureTempRoot = Path.Combine(
+ Path.GetTempPath(),
+ "pkgtest_" + Guid.NewGuid().ToString("N"));
+
+ Directory.CreateDirectory(secureTempRoot);
+
+ string path = Path.Combine(
+ secureTempRoot,
+ Path.GetRandomFileName());
+
+ // Create securely using exclusive, non-shareable access
+ using (FileStream file = new FileStream(
+ path,
+ FileMode.CreateNew,
+ FileAccess.ReadWrite,
+ FileShare.None))
+ {
+ file.Write(new byte[] { 9, 8, 7 });
+ }
+
+ File.SetLastWriteTimeUtc(
+ path,
+ new DateTime(2022, 5, 1, 12, 0, 0, DateTimeKind.Utc));
+
+ try
+ {
+ using PackageItem item = new PackageItem(path, PackageItemAttributes.ExecuteFile);
+
+ Assert.AreEqual(Path.GetFileName(path), item.Name);
+ Assert.IsTrue(item.IsAttributeSet(PackageItemAttributes.ExecuteFile));
+ Assert.IsGreaterThanOrEqualTo(3, item.DataStream.Length);
+ }
+ finally
+ {
+ // Secure cleanup: remove file then folder
+ if (File.Exists(path))
+ File.Delete(path);
+
+ if (Directory.Exists(secureTempRoot))
+ Directory.Delete(secureTempRoot, recursive: true);
+ }
+ }
+
+
+ // ---------------------------------------------------------
+ // WRITE FORMAT + RE-PARSE
+ // ---------------------------------------------------------
+
+ private static PackageItem Roundtrip(PackageItem source)
+ {
+ MemoryStream buffer = new MemoryStream(); // do NOT dispose here
+ source.WriteTo(buffer);
+
+ buffer.Position = 0;
+ return PackageItem.Parse(buffer);
+ }
+
+ [TestMethod]
+ public void WriteThenParse_ShouldReturnEquivalentName()
+ {
+ using PackageItem item = CreateMinimalWritable();
+ using PackageItem parsed = Roundtrip(item);
+
+ Assert.AreEqual(item.Name, parsed.Name);
+ }
+
+ [TestMethod]
+ public void WriteThenParse_ShouldPreserveTimestamp()
+ {
+ DateTime dt = new DateTime(2022, 10, 30, 11, 0, 0, DateTimeKind.Utc);
+ using PackageItem item = new PackageItem("f", dt, StreamOf(1, 2, 3));
+ using PackageItem parsed = Roundtrip(item);
+
+ Assert.AreEqual(dt, parsed.LastModifiedUTC);
+ }
+
+ [TestMethod]
+ public void WriteThenParse_ShouldPreserveAttributes()
+ {
+ using PackageItem item = new PackageItem("a", DateTime.UtcNow, StreamOf(1),
+ attributes: PackageItemAttributes.FixedExtractLocation);
+
+ using PackageItem parsed = Roundtrip(item);
+
+ Assert.IsTrue(parsed.IsAttributeSet(PackageItemAttributes.FixedExtractLocation));
+ }
+
+ [TestMethod]
+ public void WriteThenParse_ShouldPreserveData()
+ {
+ using PackageItem item = CreateMinimalWritable();
+ using PackageItem parsed = Roundtrip(item);
+
+ using BinaryReader reader = new BinaryReader(parsed.DataStream);
+
+ byte[] bytes = reader.ReadBytes(3);
+
+ CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, bytes);
+ }
+
+ // ---------------------------------------------------------
+ // CUSTOM EXTRACT LOCATION
+ // ---------------------------------------------------------
+
+ [TestMethod]
+ public void WriteThenParse_WithCustomLocation_ShouldRoundtrip()
+ {
+ // Create a private temp subfolder so location is not globally predictable
+ string secureTempRoot = Path.Combine(
+ Path.GetTempPath(),
+ "pkgtest_" + Guid.NewGuid().ToString("N"));
+
+ Directory.CreateDirectory(secureTempRoot);
+
+ try
+ {
+ using PackageItem item = new PackageItem(
+ "x.txt",
+ DateTime.UtcNow,
+ StreamOf(1, 2),
+ attributes: PackageItemAttributes.FixedExtractLocation,
+ extractTo: ExtractLocation.Custom,
+ extractToCustomLocation: secureTempRoot);
+
+ using PackageItem parsed = Roundtrip(item);
+
+ Assert.AreEqual(secureTempRoot, parsed.ExtractToCustomLocation);
+ }
+ finally
+ {
+ if (Directory.Exists(secureTempRoot))
+ Directory.Delete(secureTempRoot, recursive: true);
+ }
+ }
+
+ // ---------------------------------------------------------
+ // GET EXTRACTION PATH LOGIC
+ // ---------------------------------------------------------
+
+ [TestMethod]
+ public void GetExtractionFilePath_ShouldRespectFixedAttribute()
+ {
+ using PackageItem item = new PackageItem("abc.dll", DateTime.UtcNow,
+ StreamOf(1),
+ attributes: PackageItemAttributes.FixedExtractLocation,
+ extractTo: ExtractLocation.System);
+
+ string result = item.GetExtractionFilePath(ExtractLocation.Temp, null);
+
+ // path must be under System, not requested Temp
+ string expectedRoot = Package.GetExtractLocation(ExtractLocation.System, null);
+ Assert.StartsWith(expectedRoot, result);
+ }
+
+ [TestMethod]
+ public void GetExtractionFilePath_ShouldUseSuppliedLocation_WhenNotFixed()
+ {
+ using PackageItem item = new PackageItem("abc.dll", StreamOf(7));
+
+ string path = item.GetExtractionFilePath(ExtractLocation.Temp);
+
+ Assert.StartsWith(Path.GetTempPath(), path);
+ }
+
+ // ---------------------------------------------------------
+ // EXTRACTION TRANSACTION
+ // ---------------------------------------------------------
+
+ [TestMethod]
+ public void Extract_ShouldBackupExisting_WhenOverwriteEnabled()
+ {
+ string target = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ byte[] originalBytes = "c"u8.ToArray();
+
+ // Securely create target
+ using (FileStream fs = new FileStream(
+ target,
+ FileMode.CreateNew,
+ FileAccess.ReadWrite,
+ FileShare.None))
+ {
+ fs.Write(originalBytes, 0, originalBytes.Length);
+ }
+
+ string? backupPath = null;
+
+ try
+ {
+ using PackageItem item = CreateMinimalWritable();
+ PackageItemTransactionLog log = item.Extract(target, overwrite: true);
+
+ Assert.IsNotNull(log);
+ Assert.IsTrue(File.Exists(log.FilePath));
+
+ // Track for cleanup
+ backupPath = log.OriginalFilePath;
+
+ Assert.IsTrue(File.Exists(backupPath), "Backup should exist");
+
+ CollectionAssert.AreEqual(
+ originalBytes,
+ File.ReadAllBytes(backupPath));
+
+ CollectionAssert.AreEqual(
+ new byte[] { 1, 2, 3 },
+ File.ReadAllBytes(target));
+ }
+ finally
+ {
+ if (File.Exists(target))
+ File.Delete(target);
+
+ // Now valid (conditional reachability eliminated)
+ if (!string.IsNullOrWhiteSpace(backupPath) && File.Exists(backupPath))
+ File.Delete(backupPath);
+ }
+ }
+
+ [TestMethod]
+ public void Extract_ShouldNotOverwrite_WhenFlagDisabled()
+ {
+ string target = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ byte[] originalBytes = "X"u8.ToArray();
+
+ // Create file securely
+ using (FileStream fs = new FileStream(
+ target,
+ FileMode.CreateNew,
+ FileAccess.ReadWrite,
+ FileShare.None))
+ {
+ fs.Write(originalBytes, 0, originalBytes.Length);
+ }
+
+ try
+ {
+ using PackageItem item = CreateMinimalWritable();
+ PackageItemTransactionLog log = item.Extract(target, overwrite: false);
+
+ Assert.IsNull(log, "Extract must return null when overwrite=false");
+ CollectionAssert.AreEqual(originalBytes, File.ReadAllBytes(target));
+ }
+ finally
+ {
+ // cleanup
+ if (File.Exists(target))
+ File.Delete(target);
+ }
+ }
+
+ // ---------------------------------------------------------
+ // PARSE ERROR SCENARIOS
+ // ---------------------------------------------------------
+
+ [TestMethod]
+ public void Parse_ShouldThrow_WhenVersionIsUnsupported()
+ {
+ using MemoryStream buffer = StreamOf("\t"u8.ToArray() /* invalid version */);
+
+ Assert.ThrowsExactly(() =>
+ {
+ _ = PackageItem.Parse(buffer);
+ });
+ }
+
+ [TestMethod]
+ public void Parse_ShouldReturnNull_WhenEOFMarker()
+ {
+ using MemoryStream buffer = StreamOf(0);
+
+ PackageItem item = PackageItem.Parse(buffer);
+
+ Assert.IsNull(item);
+ }
+ }
+}
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/PackageTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/PackageTests.cs
new file mode 100644
index 00000000..5d27dafc
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/PackageTests.cs
@@ -0,0 +1,224 @@
+/*
+Technitium Library
+Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
+Copyright (C) 2026 Zafer Balkan (zafer@zaferbalkan.com)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.IO;
+using System.Linq;
+using System.Text;
+using TechnitiumLibrary.IO;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.IO
+{
+ [TestClass]
+ public sealed class PackageTests
+ {
+ private static byte[] BuildEmptyPackageFile()
+ {
+ // Header:
+ // TP format id
+ // 01 version
+ // 00 EOF (no items)
+ return "TP"u8.ToArray()
+ .Append((byte)1)
+ .Append((byte)0)
+ .ToArray();
+ }
+
+ // -------------------------------------------------------------
+ // CONSTRUCTION
+ // -------------------------------------------------------------
+
+ [TestMethod]
+ public void Constructor_ShouldWriteHeader_WhenCreating()
+ {
+ using MemoryStream backing = new MemoryStream();
+
+ using (Package pkg = new Package(backing, PackageMode.Create))
+ {
+ pkg.Close();
+ }
+
+ byte[] data = backing.ToArray();
+
+ Assert.IsGreaterThanOrEqualTo(3, data.Length);
+ Assert.AreEqual("TP", Encoding.ASCII.GetString(data[..2]));
+ Assert.AreEqual(1, data[2]); // version marker
+ }
+
+ [TestMethod]
+ public void Constructor_ShouldReadExisting_WhenOpening()
+ {
+ byte[] bytes = BuildEmptyPackageFile();
+ using MemoryStream backing = new MemoryStream(bytes);
+
+ using Package pkg = new Package(backing, PackageMode.Open);
+
+ Assert.IsEmpty(pkg.Items);
+ }
+
+ [TestMethod]
+ public void Constructor_ShouldThrow_WhenInvalidHeader()
+ {
+ using MemoryStream backing = new MemoryStream("XY"u8.ToArray());
+
+ Assert.ThrowsExactly(() =>
+ new Package(backing, PackageMode.Open));
+ }
+
+ // -------------------------------------------------------------
+ // MODE RESTRICTION
+ // -------------------------------------------------------------
+
+ [TestMethod]
+ public void AddItem_ShouldThrow_WhenNotInCreateMode()
+ {
+ using MemoryStream backing = new MemoryStream(BuildEmptyPackageFile());
+ using Package pkg = new Package(backing, PackageMode.Open);
+
+ Assert.ThrowsExactly(() =>
+ {
+ // simulate write by raw call — not allowed in Open mode
+ pkg.AddItem(null);
+ });
+ }
+
+ [TestMethod]
+ public void Items_ShouldThrow_WhenNotInOpenMode()
+ {
+ using MemoryStream backing = new MemoryStream();
+ using Package pkg = new Package(backing, PackageMode.Create);
+
+ Assert.ThrowsExactly(() =>
+ {
+ _ = pkg.Items;
+ });
+ }
+
+ // -------------------------------------------------------------
+ // WRITE AND READ BACK
+ // -------------------------------------------------------------
+
+ [TestMethod]
+ public void WriteAndRead_ShouldReturnSameItems()
+ {
+ using MemoryStream backing = new MemoryStream();
+
+ // Write
+ using (Package pkg = new Package(backing, PackageMode.Create))
+ {
+ pkg.AddItem(new PackageItem("A", Stream.Null));
+ pkg.Close();
+ }
+
+ // Reopen
+ backing.Position = 0;
+ using Package pkg2 = new Package(backing, PackageMode.Open);
+
+ Assert.HasCount(1, pkg2.Items);
+ }
+
+ [TestMethod]
+ public void Close_ShouldWriteEOF_Once()
+ {
+ using MemoryStream backing = new MemoryStream();
+ using Package pkg = new Package(backing, PackageMode.Create);
+ pkg.AddItem(new PackageItem("A", Stream.Null));
+ pkg.Close();
+ long len1 = backing.Length;
+ pkg.Close();
+ long len2 = backing.Length;
+ Assert.AreEqual(len1, len2);
+ }
+
+ // -------------------------------------------------------------
+ // STREAM OWNERSHIP
+ // -------------------------------------------------------------
+
+ [TestMethod]
+ public void Dispose_ShouldCloseOwnedStream()
+ {
+ // secure temp file creation
+ string tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
+ // create file exclusively before passing to Package
+ using (FileStream fs = new FileStream(
+ tempFile,
+ FileMode.CreateNew, // guarantees file does not exist
+ FileAccess.ReadWrite,
+ FileShare.None)) // no external access allowed
+ {
+ fs.WriteByte(99); // write something so the file exists
+ }
+
+ try
+ {
+ using (Package pkg = new Package(tempFile, PackageMode.Create))
+ pkg.Close(); // Close → flush EOF marker → close underlying stream
+
+ using FileStream fs = new FileStream(tempFile, FileMode.Open, FileAccess.Read);
+ Assert.IsGreaterThanOrEqualTo(3, fs.Length);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [TestMethod]
+ public void Dispose_ShouldNotCloseExternalStream()
+ {
+ using MemoryStream backing = new MemoryStream();
+ using (Package pkg = new Package(backing, PackageMode.Create, ownsStream: false))
+ pkg.Close();
+
+ // external stream still usable
+ backing.WriteByte(255);
+ backing.Position = 0;
+ }
+
+ // -------------------------------------------------------------
+ // INVALID FORMATS
+ // -------------------------------------------------------------
+
+ [TestMethod]
+ public void ShouldThrow_WhenMissingVersion()
+ {
+ using MemoryStream backing = new MemoryStream("TP"u8.ToArray());
+
+ Assert.ThrowsExactly(() =>
+ new Package(backing, PackageMode.Open));
+ }
+
+ [TestMethod]
+ public void ShouldThrow_WhenUnsupportedVersion()
+ {
+ byte[] bytes = "TP"u8.ToArray()
+ .Concat("*"u8.ToArray()) // bogus version
+ .Concat(new byte[] { 0 })
+ .ToArray();
+
+ using MemoryStream backing = new MemoryStream(bytes);
+
+ Assert.ThrowsExactly(() =>
+ new Package(backing, PackageMode.Open));
+ }
+ }
+}
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/PipeTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/PipeTests.cs
new file mode 100644
index 00000000..1ace137b
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/PipeTests.cs
@@ -0,0 +1,195 @@
+/*
+Technitium Library
+Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
+Copyright (C) 2026 Zafer Balkan (zafer@zaferbalkan.com)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using TechnitiumLibrary.IO;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.IO
+{
+ [TestClass]
+ public sealed class PipeTests
+ {
+ private static Pipe CreatePipe() => new Pipe();
+
+ // ------------------------------------------------------------
+ // CONSTRUCTION
+ // ------------------------------------------------------------
+
+ [TestMethod]
+ public void Constructor_ShouldExposeTwoConnectedStreams()
+ {
+ Pipe p = CreatePipe();
+
+ Assert.IsNotNull(p.Stream1);
+ Assert.IsNotNull(p.Stream2);
+
+ Assert.IsTrue(p.Stream1.CanRead);
+ Assert.IsTrue(p.Stream1.CanWrite);
+
+ Assert.IsTrue(p.Stream2.CanRead);
+ Assert.IsTrue(p.Stream2.CanWrite);
+ }
+
+ // ------------------------------------------------------------
+ // BASIC DATA TRANSFER
+ // ------------------------------------------------------------
+
+ [TestMethod]
+ public void WriteOnStream1_ShouldBeReadableFromStream2()
+ {
+ Pipe pipe = CreatePipe();
+ byte[] data = new byte[] { 1, 2, 3 };
+
+ pipe.Stream1.Write(data, 0, data.Length);
+
+ byte[] buffer = new byte[10];
+ int read = pipe.Stream2.Read(buffer, 0, 10);
+
+ Assert.AreEqual(3, read);
+ CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, buffer[..3]);
+ }
+
+ [TestMethod]
+ public void Read_ShouldReturnZero_WhenOtherSideDisposed()
+ {
+ Pipe pipe = CreatePipe();
+
+ pipe.Stream1.Dispose();
+
+ byte[] buffer = new byte[5];
+ int read = pipe.Stream2.Read(buffer, 0, 5);
+
+ Assert.AreEqual(0, read);
+ }
+
+ // ------------------------------------------------------------
+ // SEEK PROHIBITIONS
+ // ------------------------------------------------------------
+
+ [TestMethod]
+ public void Position_ShouldThrowOnGet()
+ {
+ Pipe pipe = CreatePipe();
+ Assert.ThrowsExactly(() => _ = pipe.Stream1.Position);
+ }
+
+ [TestMethod]
+ public void Position_ShouldThrowOnSet()
+ {
+ Pipe pipe = CreatePipe();
+ Assert.ThrowsExactly(() => pipe.Stream1.Position = 10);
+ }
+
+ [TestMethod]
+ public void Seek_ShouldThrow()
+ {
+ Pipe pipe = CreatePipe();
+ Assert.ThrowsExactly(() => pipe.Stream1.Seek(10, SeekOrigin.Begin));
+ }
+
+ [TestMethod]
+ public void Length_ShouldThrow()
+ {
+ Pipe pipe = CreatePipe();
+ Assert.ThrowsExactly(() => _ = pipe.Stream1.Length);
+ }
+
+ // ------------------------------------------------------------
+ // BUFFER BOUNDARY BEHAVIOR
+ // ------------------------------------------------------------
+
+ [TestMethod]
+ public void Write_ShouldBlockWhenBufferFull_ThenResumeAfterRead()
+ {
+ Pipe pipe = CreatePipe();
+ Stream stream1 = pipe.Stream1;
+ Stream stream2 = pipe.Stream2;
+
+ stream1.WriteTimeout = 2000;
+ stream2.ReadTimeout = 2000;
+
+ byte[] large = new byte[64 * 1024]; // exactly buffer size
+
+ // Fill buffer completely
+ stream1.Write(large, 0, large.Length);
+
+ // Now write again, but on another thread
+ using Task t = Task.Run(() =>
+ {
+ // Should block until read
+ stream1.Write(new byte[] { 7 }, 0, 1);
+ }, TestContext.CancellationToken);
+
+ // Give writer thread chance to block
+ Thread.Sleep(100);
+
+ // Now read entire buffer
+ byte[] readBuffer = new byte[large.Length];
+ int readTotal = stream2.Read(readBuffer, 0, large.Length);
+
+ Assert.AreEqual(large.Length, readTotal);
+
+ // Now writer should have completed
+ t.Wait(TestContext.CancellationToken);
+ }
+
+ [TestMethod]
+ public void Write_ShouldFailWhenTimeoutExceeded()
+ {
+ Pipe pipe = CreatePipe();
+ pipe.Stream1.WriteTimeout = 300;
+
+ // fill buffer without draining
+ pipe.Stream1.Write(new byte[64 * 1024], 0, 64 * 1024);
+
+ Assert.ThrowsExactly(() => pipe.Stream1.Write(new byte[] { 1 }, 0, 1));
+ }
+
+ [TestMethod]
+ public void Read_ShouldFailWhenTimeoutExceeded()
+ {
+ Pipe pipe = CreatePipe();
+ pipe.Stream2.ReadTimeout = 200;
+
+ byte[] buffer = new byte[1];
+
+ Assert.ThrowsExactly(() => pipe.Stream2.Read(buffer, 0, 1));
+ }
+
+ // ------------------------------------------------------------
+ // DISPOSAL CASCADE
+ // ------------------------------------------------------------
+
+ [TestMethod]
+ public void Dispose_ShouldStopOtherSideFromDeliveringData()
+ {
+ Pipe pipe = CreatePipe();
+ pipe.Stream1.Dispose();
+
+ Assert.ThrowsExactly(() => pipe.Stream1.Write(new byte[] { 1 }, 0, 1));
+ }
+
+ public TestContext TestContext { get; set; }
+ }
+}
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/StreamExtensionsTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/StreamExtensionsTests.cs
new file mode 100644
index 00000000..abe3eea5
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/StreamExtensionsTests.cs
@@ -0,0 +1,208 @@
+/*
+Technitium Library
+Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
+Copyright (C) 2026 Zafer Balkan (zafer@zaferbalkan.com)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using TechnitiumLibrary.IO;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.IO
+{
+ [TestClass]
+ public sealed class StreamExtensionsTests
+ {
+ private static MemoryStream StreamOf(params byte[] data) =>
+ new MemoryStream(data, writable: true);
+
+ // --------------------------------------------------------------------
+ // ReadByteValue & WriteByteAsync
+ // --------------------------------------------------------------------
+
+ [TestMethod]
+ public void ReadByteValue_ShouldReturnFirstByte()
+ {
+ using MemoryStream s = StreamOf("c"u8.ToArray());
+ Assert.AreEqual(99, s.ReadByteValue());
+ }
+
+ [TestMethod]
+ public void ReadByteValue_ShouldThrow_WhenEmpty()
+ {
+ using MemoryStream s = StreamOf();
+ Assert.ThrowsExactly(() => s.ReadByteValue());
+ }
+
+ [TestMethod]
+ public async Task WriteByteAsync_ShouldWriteByte()
+ {
+ await using MemoryStream s = new MemoryStream(); // expandable stream
+
+ await s.WriteByteAsync(42, TestContext.CancellationToken);
+
+ s.Position = 0;
+
+ byte value = await s.ReadByteValueAsync(TestContext.CancellationToken);
+
+ Assert.AreEqual(42, value);
+ }
+
+ // --------------------------------------------------------------------
+ // ReadExactly
+ // --------------------------------------------------------------------
+
+ [TestMethod]
+ public void ReadExactly_ShouldReturnRequestedBytes()
+ {
+ using MemoryStream s = StreamOf(1, 2, 3, 4);
+ byte[] data = s.ReadExactly(3);
+
+ CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, data);
+ }
+
+ [TestMethod]
+ public void ReadExactly_ShouldThrow_WhenInsufficientData()
+ {
+ using MemoryStream s = StreamOf(1, 2);
+ Assert.ThrowsExactly(() => s.ReadExactly(3));
+ }
+
+ [TestMethod]
+ public async Task ReadExactlyAsync_ShouldReturnRequestedBytes()
+ {
+ await using MemoryStream s = StreamOf(10, 20, 30);
+ byte[] result = await s.ReadExactlyAsync(2, TestContext.CancellationToken);
+
+ CollectionAssert.AreEqual(new byte[] { 10, 20 }, result);
+ }
+
+ [TestMethod]
+ public async Task ReadExactlyAsync_ShouldThrow_WhenStreamEnds()
+ {
+ await using MemoryStream s = StreamOf(5);
+ await Assert.ThrowsExactlyAsync(() => s.ReadExactlyAsync(2, TestContext.CancellationToken));
+ }
+
+ // --------------------------------------------------------------------
+ // Short string read/write
+ // --------------------------------------------------------------------
+
+ [TestMethod]
+ public void WriteShortString_ThenReadShortString_ShouldRoundtrip()
+ {
+ using MemoryStream s = new MemoryStream(); // expandable stream
+
+ s.WriteShortString("Hello");
+
+ s.Position = 0;
+ string str = s.ReadShortString();
+
+ Assert.AreEqual("Hello", str);
+ }
+
+ [TestMethod]
+ public void WriteShortString_ShouldThrow_WhenLengthExceeds255()
+ {
+ string oversized = new string('A', 300);
+
+ using MemoryStream s = StreamOf();
+ Assert.ThrowsExactly(() => s.WriteShortString(oversized));
+ }
+
+ [TestMethod]
+ public void ReadShortString_ShouldThrow_WhenLengthGreaterThanAvailableData()
+ {
+ using MemoryStream s = StreamOf(2, 65); // length=2, only 1 byte remains
+ Assert.ThrowsExactly(() => s.ReadShortString());
+ }
+
+ [TestMethod]
+ public async Task WriteShortStringAsync_ShouldRoundtripWithUTF8()
+ {
+ await using MemoryStream s = new MemoryStream(); // expandable
+
+ await s.WriteShortStringAsync("test✓", TestContext.CancellationToken);
+
+ s.Position = 0;
+ string parsed = await s.ReadShortStringAsync(TestContext.CancellationToken);
+
+ Assert.AreEqual("test✓", parsed);
+ }
+
+ // --------------------------------------------------------------------
+ // CopyTo & CopyToAsync
+ // --------------------------------------------------------------------
+
+ [TestMethod]
+ public void CopyTo_ShouldCopyExactBytes()
+ {
+ using MemoryStream src = StreamOf(1, 2, 3, 4);
+ using MemoryStream dst = new MemoryStream(); // must be expandable here
+
+ src.CopyTo(dst, bufferSize: 3, length: 3);
+
+ CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, dst.ToArray());
+ }
+
+ [TestMethod]
+ public void CopyTo_ShouldFailWhenEOSIsReachedPrematurely()
+ {
+ using MemoryStream src = StreamOf(1, 2);
+ using MemoryStream dst = new MemoryStream(); // must allow writing
+
+ Assert.ThrowsExactly(() =>
+ src.CopyTo(dst, bufferSize: 4, length: 3));
+ }
+
+ [TestMethod]
+ public async Task CopyToAsync_ShouldCopyExactBytes()
+ {
+ await using MemoryStream src = StreamOf("cba"u8.ToArray());
+ await using MemoryStream dst = new MemoryStream(); // expandable destination
+
+ await src.CopyToAsync(dst, bufferSize: 10, length: 3, TestContext.CancellationToken);
+
+ CollectionAssert.AreEqual("cba"u8.ToArray(), dst.ToArray());
+ }
+
+ [TestMethod]
+ public async Task CopyToAsync_ShouldFailWhenEOSReachedPrematurely()
+ {
+ await using MemoryStream src = StreamOf("\t"u8.ToArray());
+ await using MemoryStream dst = new MemoryStream(); // expandable
+
+ await Assert.ThrowsExactlyAsync(async () =>
+ await src.CopyToAsync(dst, bufferSize: 8, length: 2, TestContext.CancellationToken));
+ }
+
+ [TestMethod]
+ public void CopyTo_ShouldReturnImmediately_WhenLengthIsZero()
+ {
+ using MemoryStream src = StreamOf(1, 2, 3);
+ using MemoryStream dst = StreamOf();
+
+ src.CopyTo(dst, bufferSize: 5, length: 0);
+
+ Assert.IsEmpty(dst.ToArray());
+ }
+
+ public TestContext TestContext { get; set; }
+ }
+}
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/WriteBufferedStreamTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/WriteBufferedStreamTests.cs
new file mode 100644
index 00000000..cfd1b3d7
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.IO/WriteBufferedStreamTests.cs
@@ -0,0 +1,299 @@
+/*
+Technitium Library
+Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
+Copyright (C) 2026 Zafer Balkan (zafer@zaferbalkan.com)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using TechnitiumLibrary.IO;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.IO
+{
+ [TestClass]
+ public sealed class WriteBufferedStreamTests
+ {
+ private sealed class NonWritableStream : MemoryStream
+ {
+ public override bool CanWrite => false;
+ }
+
+ private static MemoryStream CreateBaseStream(byte[]? initial = null) =>
+ initial is null ? new MemoryStream() : new MemoryStream(initial);
+
+ // ------------------------------------------------------
+ // CONSTRUCTION / CAPABILITIES
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Constructor_ShouldThrow_WhenBaseStreamNotWritable()
+ {
+ // GIVEN
+ using NonWritableStream baseStream = new NonWritableStream();
+
+ // WHEN-THEN
+ Assert.ThrowsExactly(
+ () => new WriteBufferedStream(baseStream));
+ }
+
+ [TestMethod]
+ public void Constructor_ShouldExposeCapabilitiesFromBaseStream()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+
+ // WHEN
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+
+ // THEN
+ Assert.IsTrue(buffered.CanWrite);
+ Assert.AreEqual(baseStream.CanRead, buffered.CanRead);
+ Assert.AreEqual(baseStream.CanTimeout, buffered.CanTimeout);
+ Assert.IsFalse(buffered.CanSeek);
+ }
+
+ // ------------------------------------------------------
+ // BASIC WRITE & FLUSH (SYNC)
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Write_ShouldBufferUntilFlushed()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream, bufferSize: 8);
+
+ byte[] data = Encoding.ASCII.GetBytes("ABCD"); // 4 bytes
+
+ // WHEN
+ buffered.Write(data, 0, data.Length);
+
+ // THEN – nothing written yet to base
+ CollectionAssert.AreEqual(Array.Empty(), baseStream.ToArray());
+ Assert.AreEqual(0L, baseStream.Length);
+
+ // WHEN
+ buffered.Flush();
+
+ // THEN – data should now exist in base stream
+ CollectionAssert.AreEqual(data, baseStream.ToArray());
+ }
+
+ [TestMethod]
+ public void Write_ShouldFlushBufferWhenFull_AndKeepRemainderBuffered()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream, bufferSize: 4);
+
+ // 6 bytes, buffer 4 -> first 4 flushed, last 2 remain buffered after Flush
+ byte[] data = Encoding.ASCII.GetBytes("ABCDEF");
+
+ // WHEN
+ buffered.Write(data, 0, data.Length);
+
+ // buffer is full internally twice, so Flush() is invoked from Write
+ // After Write completes, we call Flush() to ensure remainder is written.
+ buffered.Flush();
+
+ // THEN
+ CollectionAssert.AreEqual(data, baseStream.ToArray());
+ }
+
+ // ------------------------------------------------------
+ // BASIC WRITE & FLUSH (ASYNC)
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public async Task WriteAsync_ShouldBufferAndFlushAsync()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream, bufferSize: 8);
+
+ byte[] data = Encoding.UTF8.GetBytes("123456");
+
+ // WHEN
+ await buffered.WriteAsync(data, 0, data.Length, CancellationToken.None);
+
+ // Still buffered
+ CollectionAssert.AreEqual(Array.Empty(), baseStream.ToArray());
+
+ await buffered.FlushAsync(CancellationToken.None);
+
+ // THEN
+ CollectionAssert.AreEqual(data, baseStream.ToArray());
+ }
+
+ [TestMethod]
+ public async Task WriteAsync_MemoryOverload_ShouldRespectBuffering()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream, bufferSize: 4);
+
+ byte[] data = Encoding.ASCII.GetBytes("WXYZ12"); // 6 bytes
+
+ // WHEN
+ await buffered.WriteAsync(data.AsMemory(), CancellationToken.None);
+ await buffered.FlushAsync(CancellationToken.None);
+
+ // THEN
+ CollectionAssert.AreEqual(data, baseStream.ToArray());
+ }
+
+ // ------------------------------------------------------
+ // READ DELEGATION
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Read_ShouldDelegateToBaseStream()
+ {
+ // GIVEN
+ byte[] initial = Encoding.ASCII.GetBytes("HELLO");
+ using MemoryStream baseStream = CreateBaseStream(initial);
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+
+ // WHEN
+ byte[] buffer = new byte[5];
+ baseStream.Position = 0; // ensure we read from start
+ int read = buffered.Read(buffer, 0, buffer.Length);
+
+ // THEN
+ Assert.AreEqual(5, read);
+ CollectionAssert.AreEqual(initial, buffer);
+ }
+
+ // ------------------------------------------------------
+ // SEEK / LENGTH / POSITION BEHAVIOR
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Position_Get_ShouldMatchBaseStreamPosition()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream(new byte[10]);
+ baseStream.Position = 4;
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+
+ // WHEN
+ long position = buffered.Position;
+
+ // THEN
+ Assert.AreEqual(4L, position);
+ }
+
+ [TestMethod]
+ public void Position_Set_ShouldThrow_NotSupported()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+
+ // WHEN-THEN
+ Assert.ThrowsExactly(() =>
+ buffered.Position = 1);
+ }
+
+ [TestMethod]
+ public void Seek_ShouldThrow_NotSupported()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+
+ // WHEN-THEN
+ Assert.ThrowsExactly(() =>
+ buffered.Seek(0, SeekOrigin.Begin));
+ }
+
+ [TestMethod]
+ public void SetLength_ShouldThrow_NotSupported()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+
+ // WHEN-THEN
+ Assert.ThrowsExactly(() =>
+ buffered.SetLength(10));
+ }
+
+ // ------------------------------------------------------
+ // DISPOSAL & OWNERSHIP
+ // ------------------------------------------------------
+
+ [TestMethod]
+ public void Dispose_ShouldDisposeUnderlyingStream()
+ {
+ // GIVEN
+ MemoryStream baseStream = CreateBaseStream();
+ WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+
+ // WHEN
+ buffered.Dispose();
+
+ // THEN – base stream also disposed
+ Assert.ThrowsExactly(() =>
+ baseStream.WriteByte(1));
+ }
+
+ [TestMethod]
+ public void Write_ShouldThrow_WhenDisposed()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+ buffered.Dispose();
+
+ // WHEN-THEN
+ Assert.ThrowsExactly(() =>
+ buffered.Write(new byte[] { 1 }, 0, 1));
+ }
+
+ [TestMethod]
+ public async Task WriteAsync_ShouldThrow_WhenDisposed()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+ buffered.Dispose();
+
+ // WHEN-THEN
+ await Assert.ThrowsExactlyAsync(() =>
+ buffered.WriteAsync(new byte[] { 1 }, 0, 1, CancellationToken.None));
+ }
+
+ [TestMethod]
+ public async Task FlushAsync_ShouldNotFlush_WhenNothingBuffered()
+ {
+ // GIVEN
+ using MemoryStream baseStream = CreateBaseStream();
+ using WriteBufferedStream buffered = new WriteBufferedStream(baseStream);
+
+ // WHEN
+ await buffered.FlushAsync(CancellationToken.None);
+
+ // THEN – nothing written
+ CollectionAssert.AreEqual(Array.Empty(), baseStream.ToArray());
+ }
+ }
+}
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj
new file mode 100644
index 00000000..9aab8e95
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net9.0
+ latest
+ disable
+ enable
+ true
+
+
+
+
+
+
+
diff --git a/TechnitiumLibrary.sln b/TechnitiumLibrary.sln
index 9cbcda3e..bfc3298a 100644
--- a/TechnitiumLibrary.sln
+++ b/TechnitiumLibrary.sln
@@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary", "Techni
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary.Security.OTP", "TechnitiumLibrary.Security.OTP\TechnitiumLibrary.Security.OTP.csproj", "{72AF4EB6-EB81-4655-9998-8BF24B304614}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary.UnitTests", "TechnitiumLibrary.UnitTests\TechnitiumLibrary.UnitTests.csproj", "{D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -75,6 +77,10 @@ Global
{72AF4EB6-EB81-4655-9998-8BF24B304614}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72AF4EB6-EB81-4655-9998-8BF24B304614}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72AF4EB6-EB81-4655-9998-8BF24B304614}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE