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 + +[![Unit testing (Windows / MSBuild)](https://github.com/TechnitiumSoftware/TechnitiumLibrary/actions/workflows/unit-testing.yml/badge.svg)](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