From 1e0a8398ec26a09535502b587897d89a4ae44cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 15 May 2026 09:06:25 +0200 Subject: [PATCH] feat: Phase 5.1 surface IMockFileDataAccessor enumeration properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestableIO's MockFileSystem exposes four enumeration properties through IMockFileDataAccessor — AllPaths, AllFiles, AllDirectories, AllDrives — that enumerate the entire mocked file system. Testably has no direct equivalent. The natural replacements (Directory.EnumerateFiles / EnumerateDirectories against a root path, or DriveInfo.GetDrives() + SelectMany for multi-drive setups) require context the analyzer cannot infer safely, so each property gets its own pattern id and the code-fix provider intentionally falls through with no rewrite — the user picks the right replacement per call site. Analyzer changes: - AnalyzePropertyReference now handles MockFileSystem and IMockFileDataAccessor receivers in addition to MockFileData. The new branch classifies the property name via ClassifyEnumerationProperty and reports the discriminating pattern id before the existing MockFileData logic. - The receiver-type gate covers both the concrete class (when the property is hidden/re-declared on MockFileSystem) and the interface (when the receiver is typed as IMockFileDataAccessor). Tests cover all four properties on both receiver types and HasNoFix for each. Playground gains parity samples for AllFiles and AllDirectories. --- .../Patterns.cs | 21 +++++++++ .../SystemIOAbstractionsAnalyzer.cs | 37 ++++++++++++++-- .../AccessorMethodTests.cs | 42 ++++++++++++++++++ .../SystemIOAbstractionsAnalyzerTests.cs | 43 +++++++++++++++++++ ...odeFixProviderTests.AccessorMethodTests.cs | 27 ++++++++++++ 5 files changed, 167 insertions(+), 3 deletions(-) diff --git a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs index 611ef98..1db5a3e 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs @@ -57,6 +57,27 @@ public static class Patterns /// public const string MockFileSystemAddDrive = "MockFileSystem.AddDrive"; + // ── Enumeration properties (Phase 5.1) ──────────────────────────────── + // These IMockFileDataAccessor properties enumerate the whole mocked file + // system. Testably has no direct equivalent — the natural replacements + // (Directory.EnumerateFiles/EnumerateDirectories, DriveInfo.GetDrives, + // etc.) need a root path or drive scope the analyzer cannot infer safely. + // Each property gets its own pattern id so manual migration is + // discoverable per call site; the code-fix provider intentionally + // registers no rewrite. + + /// fs.AllPaths — union of files and directories across the mocked file system. + public const string MockFileSystemAllPaths = "MockFileSystem.AllPaths"; + + /// fs.AllFiles — every mocked file path. + public const string MockFileSystemAllFiles = "MockFileSystem.AllFiles"; + + /// fs.AllDirectories — every mocked directory path. + public const string MockFileSystemAllDirectories = "MockFileSystem.AllDirectories"; + + /// fs.AllDrives — every mocked drive name. + public const string MockFileSystemAllDrives = "MockFileSystem.AllDrives"; + // ── MockFileData property access ────────────────────────────────────── /// A read access to a MockFileData property. diff --git a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs index 055731a..63932a5 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs @@ -179,13 +179,35 @@ symbols.MockFileDataAccessor is { } accessor private static void AnalyzePropertyReference(OperationAnalysisContext context, TestableIoSymbols symbols) { - if (symbols.MockFileData is null - || context.Operation is not IPropertyReferenceOperation propertyRef) + if (context.Operation is not IPropertyReferenceOperation propertyRef) { return; } - if (!SymbolEqualityComparer.Default.Equals(propertyRef.Property.ContainingType, symbols.MockFileData)) + INamedTypeSymbol? containingType = propertyRef.Property.ContainingType; + if (containingType is null) + { + return; + } + + // Phase 5.1: IMockFileDataAccessor enumeration properties (AllPaths, + // AllFiles, AllDirectories, AllDrives). These have no 1:1 Testably + // equivalent — manual migration only. + bool onMockFileSystem = SymbolEqualityComparer.Default.Equals(containingType, symbols.MockFileSystem); + bool onAccessor = symbols.MockFileDataAccessor is { } accessor + && SymbolEqualityComparer.Default.Equals(containingType, accessor); + if (onMockFileSystem || onAccessor) + { + string? enumerationPattern = ClassifyEnumerationProperty(propertyRef.Property.Name); + if (enumerationPattern is not null) + { + Report(context, propertyRef.Syntax.GetLocation(), enumerationPattern); + return; + } + } + + if (symbols.MockFileData is null + || !SymbolEqualityComparer.Default.Equals(containingType, symbols.MockFileData)) { return; } @@ -249,6 +271,15 @@ private static void AnalyzePropertyReference(OperationAnalysisContext context, T _ => null, }; + private static string? ClassifyEnumerationProperty(string propertyName) => propertyName switch + { + "AllPaths" => Patterns.MockFileSystemAllPaths, + "AllFiles" => Patterns.MockFileSystemAllFiles, + "AllDirectories" => Patterns.MockFileSystemAllDirectories, + "AllDrives" => Patterns.MockFileSystemAllDrives, + _ => null, + }; + private static string? ClassifyMockFileSystemConstructor(IMethodSymbol constructor) { ImmutableArray parameters = constructor.Parameters; diff --git a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs index bebe718..e62fafc 100644 --- a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs +++ b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs @@ -135,6 +135,48 @@ public async Task AddDrive_WithTotalSize_RegistersDriveWithSize() await That(drive!.TotalSize).IsEqualTo(totalSize); } + [Fact] + public async Task AllFiles_EnumeratesEveryAddedFile() + { + // Phase 5.1 manual-review fixture: Testably has no AllFiles equivalent. The + // migration target depends on the user's drive layout — Directory.EnumerateFiles + // against the right root, or DriveInfo.GetDrives() + SelectMany for multi-drive + // setups. The playground keeps the parity baseline so a human can decide. + MockFileSystem fs = new(); + fs.AddFile("/a/one.txt", new MockFileData("1")); + fs.AddFile("/b/two.txt", new MockFileData("2")); + + bool sawOne = false; + bool sawTwo = false; + foreach (string path in fs.AllFiles) + { + sawOne |= path.EndsWith("one.txt", StringComparison.Ordinal); + sawTwo |= path.EndsWith("two.txt", StringComparison.Ordinal); + } + + await That(sawOne).IsTrue(); + await That(sawTwo).IsTrue(); + } + + [Fact] + public async Task AllDirectories_EnumeratesEveryAddedDirectory() + { + MockFileSystem fs = new(); + fs.AddDirectory("/a/x"); + fs.AddDirectory("/b/y"); + + bool sawX = false; + bool sawY = false; + foreach (string path in fs.AllDirectories) + { + sawX |= path.EndsWith("x", StringComparison.Ordinal); + sawY |= path.EndsWith("y", StringComparison.Ordinal); + } + + await That(sawX).IsTrue(); + await That(sawY).IsTrue(); + } + private static IDriveInfo? FindDriveByName(IDriveInfo[] drives, string prefix) { foreach (IDriveInfo drive in drives) diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs index 959a2cc..64d965d 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs @@ -142,6 +142,49 @@ await Verifier.VerifyAnalyzerAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); } + [Theory] + [InlineData("AllPaths")] + [InlineData("AllFiles")] + [InlineData("AllDirectories")] + [InlineData("AllDrives")] + public async Task EnumerationProperty_OnMockFileSystem_ShouldBeFlagged(string property) + { + string source = $$""" + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IEnumerable Read(MockFileSystem fs) => {|#0:fs.{{property}}|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + + [Fact] + public async Task EnumerationProperty_OnAccessorInterface_ShouldBeFlagged() + { + // AllFiles is declared on IMockFileDataAccessor — when the receiver is the + // interface type itself, the property symbol's containing type points at the + // interface, which the analyzer must still recognise. + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IEnumerable Read(IMockFileDataAccessor accessor) => {|#0:accessor.AllFiles|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + [Theory] [InlineData("TextContents")] [InlineData("Contents")] diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs index 5962e04..c1d8540 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs @@ -678,5 +678,32 @@ await Verifier.VerifyCodeFixAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), source); } + + [Theory] + [InlineData("AllPaths")] + [InlineData("AllFiles")] + [InlineData("AllDirectories")] + [InlineData("AllDrives")] + public async Task EnumerationProperty_HasNoFix(string property) + { + // Testably has no 1:1 equivalent for the IMockFileDataAccessor enumeration + // properties. The natural replacements (Directory.EnumerateFiles, etc.) + // require a root path or drive scope the analyzer cannot infer safely, so + // the fix dispatcher intentionally falls through with no rewrite. + string source = $$""" + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IEnumerable Read(MockFileSystem fs) => {|#0:fs.{{property}}|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } } }