From 35024240468d82d0210609118305057393352b4c Mon Sep 17 00:00:00 2001 From: mpdreamz Date: Wed, 30 Sep 2026 17:15:11 +0200 Subject: [PATCH] fix: read MockFileInfo/MockDirectoryInfo state lazily like FileSystemInfo The real FileSystemInfo does not read the file's state when it is constructed. It takes one snapshot on the first read of a cached property (Exists, Length, Attributes, the times) and serves every property from it until Refresh() or an operation on the same instance invalidates it. MockFileInfo and MockDirectoryInfo took the snapshot in their constructor, so an instance created before a file or directory was created, deleted or changed reported the state from construction time: Exists false for a file created afterwards, Exists true for one deleted afterwards, stale Length, Attributes and times, and FileNotFoundException from Length for a file that exists by the time it is read. The constructors now only mark the snapshot as pending, so it is taken on first access and then cached as before; Refresh() clears the pending flag. Instances returned by DirectoryInfo enumeration are refreshed while enumerating, matching the real enumeration, which fills the snapshot from the enumeration data. MoveTo discards the snapshot, as it does in the runtime. The two "ShouldReturnCachedData" tests asserted the construction-time snapshot; they now read Exists once before the change so they keep testing that the snapshot is cached after the first read. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TymoqrVYgZwdNSper5ZZB1 --- .../MockDirectoryInfo.cs | 21 +- .../MockFileInfo.cs | 6 +- .../MockDirectoryInfoTests.cs | 102 +++++++++- .../MockFileInfoTests.cs | 184 +++++++++++++++++- 4 files changed, 307 insertions(+), 6 deletions(-) diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockDirectoryInfo.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockDirectoryInfo.cs index 8099ead93..127bca599 100644 --- a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockDirectoryInfo.cs +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockDirectoryInfo.cs @@ -39,7 +39,8 @@ public MockDirectoryInfo(IMockFileDataAccessor mockFileDataAccessor, string dire } SetDirectoryPath(directoryPath); - Refresh(); + // Like the real FileSystemInfo, the state is read on first access, not on construction. + refreshOnNextRead = true; } #if FEATURE_CREATE_SYMBOLIC_LINK @@ -62,6 +63,7 @@ public override void Refresh() { var mockFileData = mockFileDataAccessor.GetFile(directoryPath) ?? MockFileData.NullObject; cachedMockFileData = mockFileData.Clone(); + refreshOnNextRead = false; } #if FEATURE_CREATE_SYMBOLIC_LINK @@ -304,7 +306,13 @@ public override IDirectoryInfo[] GetDirectories(string searchPattern, Enumeratio private DirectoryInfoBase[] ConvertStringsToDirectories(IEnumerable paths) { return paths - .Select(path => new MockDirectoryInfo(mockFileDataAccessor, path)) + .Select(path => + { + // Enumeration results carry the state found while enumerating. + var directoryInfo = new MockDirectoryInfo(mockFileDataAccessor, path); + directoryInfo.Refresh(); + return directoryInfo; + }) .Cast() .ToArray(); } @@ -338,7 +346,13 @@ public override IFileInfo[] GetFiles(string searchPattern, EnumerationOptions en IFileInfo[] ConvertStringsToFiles(IEnumerable paths) { return paths - .Select(mockFileDataAccessor.FileInfo.New) + .Select(path => + { + // Enumeration results carry the state found while enumerating. + var fileInfo = mockFileDataAccessor.FileInfo.New(path); + fileInfo.Refresh(); + return fileInfo; + }) .ToArray(); } @@ -373,6 +387,7 @@ public override void MoveTo(string destDirName) { mockFileDataAccessor.Directory.Move(directoryPath, destDirName); SetDirectoryPath(destDirName); + refreshOnNextRead = true; } /// diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileInfo.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileInfo.cs index 188ea3e0a..de49aa90f 100644 --- a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileInfo.cs +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileInfo.cs @@ -24,7 +24,8 @@ public MockFileInfo(IMockFileDataAccessor mockFileSystem, string path) : base(mo this.originalPath = path; this.path = mockFileSystem.Path.GetFullPath(path); this.mockFile = new MockFile(mockFileSystem); - Refresh(); + // Like the real FileSystemInfo, the state is read on first access, not on construction. + refreshOnNextRead = true; } #if FEATURE_CREATE_SYMBOLIC_LINK @@ -47,6 +48,7 @@ public override void Refresh() { var mockFileData = mockFileSystem.GetFile(path)?.Clone(); cachedMockFileData = mockFileData ?? MockFileData.NullObject.Clone(); + refreshOnNextRead = false; } #if FEATURE_CREATE_SYMBOLIC_LINK @@ -265,6 +267,7 @@ public override void MoveTo(string destFileName) { mockFile.Move(path, destFileName); path = mockFileSystem.Path.GetFullPath(destFileName); + refreshOnNextRead = true; } #if FEATURE_FILE_MOVE_WITH_OVERWRITE @@ -273,6 +276,7 @@ public override void MoveTo(string destFileName, bool overwrite) { mockFile.Move(path, destFileName, overwrite); path = mockFileSystem.Path.GetFullPath(destFileName); + refreshOnNextRead = true; } #endif diff --git a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockDirectoryInfoTests.cs b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockDirectoryInfoTests.cs index bed4d6702..bed8c2ef1 100644 --- a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockDirectoryInfoTests.cs +++ b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockDirectoryInfoTests.cs @@ -465,6 +465,24 @@ public async Task MockDirectoryInfo_ToString_ShouldReturnDirectoryName(string di await That(mockDirectoryInfo.ToString()).IsEqualTo(directoryPath); } + [Test] + public async Task MockDirectoryInfo_MoveTo_ShouldDiscardTheCachedData() + { + // Arrange + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\folder"); + fileSystem.AddDirectory(path); + var directoryInfo = fileSystem.DirectoryInfo.New(path); + _ = directoryInfo.Attributes; + fileSystem.File.SetAttributes(path, FileAttributes.Directory | FileAttributes.Hidden); + + // Act + directoryInfo.MoveTo(XFS.Path(@"c:\temp\moved")); + + // Assert + await That(directoryInfo.Attributes.HasFlag(FileAttributes.Hidden)).IsTrue(); + } + [Test] public async Task MockDirectoryInfo_Exists_ShouldReturnCachedData() { @@ -472,6 +490,7 @@ public async Task MockDirectoryInfo_Exists_ShouldReturnCachedData() var fileSystem = new MockFileSystem(); var path = XFS.Path(@"c:\abc"); var directoryInfo = fileSystem.DirectoryInfo.New(path); + _ = directoryInfo.Exists; // Act fileSystem.AddDirectory(path); @@ -721,4 +740,85 @@ public async Task MockDirectoryInfo_LastWriteTimeUtc_SetterShouldThrowDirectoryN await That(() => directoryInfo.LastWriteTime = newTime).Throws(); } -} \ No newline at end of file + [Test] + public async Task MockDirectoryInfo_Exists_ShouldNotReadStateBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\abc"); + var directoryInfo = fileSystem.DirectoryInfo.New(path); + + fileSystem.Directory.CreateDirectory(path); + + await That(directoryInfo.Exists).IsTrue(); + } + + [Test] + public async Task MockDirectoryInfo_Exists_ShouldBeFalseWhenDeletedBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\abc"); + fileSystem.Directory.CreateDirectory(path); + var directoryInfo = fileSystem.DirectoryInfo.New(path); + + fileSystem.Directory.Delete(path); + + await That(directoryInfo.Exists).IsFalse(); + } + + [Test] + public async Task MockDirectoryInfo_Exists_ShouldStayTrueWhenDeletedAfterFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\abc"); + fileSystem.Directory.CreateDirectory(path); + var directoryInfo = fileSystem.DirectoryInfo.New(path); + _ = directoryInfo.Exists; + + fileSystem.Directory.Delete(path); + + await That(directoryInfo.Exists).IsTrue(); + directoryInfo.Refresh(); + await That(directoryInfo.Exists).IsFalse(); + } + + [Test] + public async Task MockDirectoryInfo_LastWriteTime_ShouldReflectChangesBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\abc"); + fileSystem.Directory.CreateDirectory(path); + var directoryInfo = fileSystem.DirectoryInfo.New(path); + var date = new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Local); + + fileSystem.Directory.SetLastWriteTime(path, date); + + await That(directoryInfo.LastWriteTime).IsEqualTo(date); + } + + [Test] + public async Task MockDirectoryInfo_LastWriteTime_ShouldReturnCachedDataAfterFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\abc"); + fileSystem.Directory.CreateDirectory(path); + var directoryInfo = fileSystem.DirectoryInfo.New(path); + var before = directoryInfo.LastWriteTime; + + fileSystem.Directory.SetLastWriteTime(path, new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Local)); + + await That(directoryInfo.LastWriteTime).IsEqualTo(before); + } + + [Test] + public async Task MockDirectoryInfo_FromEnumeration_ShouldHoldStateFromEnumeration() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\abc\sub"); + fileSystem.Directory.CreateDirectory(path); + var directoryInfo = fileSystem.DirectoryInfo.New(XFS.Path(@"c:\abc")).GetDirectories()[0]; + + fileSystem.Directory.Delete(path); + + await That(directoryInfo.Exists).IsTrue(); + } +} diff --git a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileInfoTests.cs b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileInfoTests.cs index 55a7b1f01..851ecc6b4 100644 --- a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileInfoTests.cs +++ b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileInfoTests.cs @@ -872,6 +872,24 @@ public async Task MockFileInfo_Replace_ShouldThrowIfDestinationFileDoesNotExist( await That(() => fileInfo.Replace(path2, null)).Throws(); } + [Test] + public async Task MockFileInfo_MoveTo_ShouldDiscardTheCachedData() + { + // Arrange + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("abc")); + var fileInfo = fileSystem.FileInfo.New(path); + _ = fileInfo.Length; + fileSystem.File.WriteAllText(path, "abcdefghij"); + + // Act + fileInfo.MoveTo(XFS.Path(@"c:\temp\file2.txt")); + + // Assert + await That(fileInfo.Length).IsEqualTo(10); + } + [Test] public async Task MockFileInfo_Exists_ShouldReturnCachedData() { @@ -879,6 +897,7 @@ public async Task MockFileInfo_Exists_ShouldReturnCachedData() var fileSystem = new MockFileSystem(); var path1 = XFS.Path(@"c:\temp\file1.txt"); var fileInfo = fileSystem.FileInfo.New(path1); + _ = fileInfo.Exists; // Act fileSystem.AddFile(path1, new MockFileData("1")); @@ -1054,4 +1073,167 @@ public async Task MockFileInfo_LastWriteTimeWithUnspecifiedDateTimeKind_ShouldSe await That(fileInfo.LastWriteTime).IsEqualTo(date); await That(fileInfo.LastWriteTime.Kind).IsNotEqualTo(DateTimeKind.Unspecified); } -} \ No newline at end of file + + [Test] + public async Task MockFileInfo_Exists_ShouldNotReadStateBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + var fileInfo = fileSystem.FileInfo.New(path); + + fileSystem.AddFile(path, new MockFileData("1")); + + await That(fileInfo.Exists).IsTrue(); + } + + [Test] + public async Task MockFileInfo_Exists_ShouldBeFalseWhenDeletedBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("1")); + var fileInfo = fileSystem.FileInfo.New(path); + + fileSystem.File.Delete(path); + + await That(fileInfo.Exists).IsFalse(); + } + + [Test] + public async Task MockFileInfo_Exists_ShouldStayTrueWhenDeletedAfterFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("1")); + var fileInfo = fileSystem.FileInfo.New(path); + _ = fileInfo.Exists; + + fileSystem.File.Delete(path); + + await That(fileInfo.Exists).IsTrue(); + } + + [Test] + public async Task MockFileInfo_Length_ShouldReturnLengthWhenFileCreatedBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + var fileInfo = fileSystem.FileInfo.New(path); + + fileSystem.AddFile(path, new MockFileData("abc")); + + await That(fileInfo.Length).IsEqualTo(3); + } + + [Test] + public async Task MockFileInfo_Length_ShouldThrowFileNotFoundWhenFileDeletedBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("abc")); + var fileInfo = fileSystem.FileInfo.New(path); + + fileSystem.File.Delete(path); + + await That(() => fileInfo.Length).Throws(); + } + + [Test] + public async Task MockFileInfo_Length_ShouldReflectChangesBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("abc")); + var fileInfo = fileSystem.FileInfo.New(path); + + fileSystem.File.AppendAllText(path, "defg"); + + await That(fileInfo.Length).IsEqualTo(7); + } + + [Test] + public async Task MockFileInfo_Length_ShouldReturnCachedDataAfterFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("abc")); + var fileInfo = fileSystem.FileInfo.New(path); + _ = fileInfo.Length; + + fileSystem.File.AppendAllText(path, "defg"); + + await That(fileInfo.Length).IsEqualTo(3); + fileInfo.Refresh(); + await That(fileInfo.Length).IsEqualTo(7); + } + + [Test] + public async Task MockFileInfo_FirstAccess_ShouldSnapshotAllProperties() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("abc")); + var fileInfo = fileSystem.FileInfo.New(path); + _ = fileInfo.Exists; + + fileSystem.File.AppendAllText(path, "defg"); + fileSystem.File.SetAttributes(path, FileAttributes.Hidden); + + await That(fileInfo.Length).IsEqualTo(3); + await That(fileInfo.Attributes).IsNotEqualTo(FileAttributes.Hidden); + } + + [Test] + public async Task MockFileInfo_Attributes_ShouldReflectChangesBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("abc")); + var fileInfo = fileSystem.FileInfo.New(path); + + fileSystem.File.SetAttributes(path, FileAttributes.Hidden); + + await That(fileInfo.Attributes).IsEqualTo(FileAttributes.Hidden); + } + + [Test] + public async Task MockFileInfo_LastWriteTime_ShouldReflectChangesBeforeFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("abc")); + var fileInfo = fileSystem.FileInfo.New(path); + var date = new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Local); + + fileSystem.File.SetLastWriteTime(path, date); + + await That(fileInfo.LastWriteTime).IsEqualTo(date); + } + + [Test] + public async Task MockFileInfo_LastWriteTime_ShouldReturnCachedDataAfterFirstAccess() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("abc")); + var fileInfo = fileSystem.FileInfo.New(path); + var before = fileInfo.LastWriteTime; + + fileSystem.File.SetLastWriteTime(path, new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Local)); + + await That(fileInfo.LastWriteTime).IsEqualTo(before); + } + + [Test] + public async Task MockFileInfo_FromEnumeration_ShouldHoldStateFromEnumeration() + { + var fileSystem = new MockFileSystem(); + var path = XFS.Path(@"c:\temp\file1.txt"); + fileSystem.AddFile(path, new MockFileData("abc")); + var fileInfo = fileSystem.DirectoryInfo.New(XFS.Path(@"c:\temp")).GetFiles()[0]; + + fileSystem.File.AppendAllText(path, "defg"); + + await That(fileInfo.Length).IsEqualTo(3); + } +}