Skip to content

Commit 76ede11

Browse files
committed
implement file system strategies to find files in a case-insensitive manner
1 parent 8e3ceb9 commit 76ede11

23 files changed

Lines changed: 1356 additions & 1138 deletions
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO.Abstractions;
4+
using System.Runtime.InteropServices;
5+
using PG.StarWarsGame.Engine.IO;
6+
using PG.StarWarsGame.Engine.Utilities;
7+
using Testably.Abstractions;
8+
using Xunit;
9+
10+
namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies;
11+
12+
public abstract class FileExistsStrategyTestBase : TestBaseWithPGFileSystem, IDisposable
13+
{
14+
private readonly List<string> _tempDirs = [];
15+
16+
protected override IFileSystem CreateFileSystem()
17+
{
18+
return new RealFileSystem();
19+
}
20+
21+
protected abstract override void ConfigureStrategy(PetroglyphFileSystem fs);
22+
23+
protected virtual void AssertResolvedPath(string expectedOnDiskPath, string actualResult)
24+
{
25+
var expected = expectedOnDiskPath.Replace('\\', FileSystem.Path.DirectorySeparatorChar).Replace('/', FileSystem.Path.DirectorySeparatorChar);
26+
var actual = actualResult.Replace('\\', FileSystem.Path.DirectorySeparatorChar).Replace('/', FileSystem.Path.DirectorySeparatorChar);
27+
var ignoreCase = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
28+
Assert.Equal(expected, actual, ignoreCase: ignoreCase);
29+
}
30+
31+
public virtual void Dispose()
32+
{
33+
foreach (var dir in _tempDirs)
34+
{
35+
try
36+
{
37+
if (FileSystem.Directory.Exists(dir))
38+
FileSystem.Directory.Delete(dir, recursive: true);
39+
}
40+
catch
41+
{
42+
// Ignore
43+
}
44+
}
45+
GC.SuppressFinalize(this);
46+
}
47+
48+
protected string NewTempDir()
49+
{
50+
var dir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString());
51+
FileSystem.Directory.CreateDirectory(dir);
52+
_tempDirs.Add(dir);
53+
return dir;
54+
}
55+
56+
// ---------------------------------------------------------------------------------------------
57+
// Shared tests — every strategy must satisfy.
58+
// ---------------------------------------------------------------------------------------------
59+
60+
[Theory]
61+
[InlineData("/gameDir")]
62+
[InlineData(null)]
63+
public void FileExists_ExistingFullyQualifiedFile_ReturnsTrue(string? gameDir)
64+
{
65+
var dir = NewTempDir();
66+
var file = FileSystem.Path.Combine(dir, "tmp.dat");
67+
FileSystem.File.WriteAllText(file, "x");
68+
69+
var fullGameDir = gameDir is null ? null : FileSystem.Path.GetFullPath(gameDir);
70+
71+
var sb = new ValueStringBuilder();
72+
var exists = PgFileSystem.FileExists(file.AsSpan(), ref sb, fullGameDir.AsSpan());
73+
74+
Assert.True(exists);
75+
AssertResolvedPath(file, sb.ToString());
76+
}
77+
78+
[Theory]
79+
[InlineData("/gameDir")]
80+
[InlineData(null)]
81+
public void FileExists_NonExistingFullyQualifiedFile_ReturnsFalse(string? gameDir)
82+
{
83+
var missing = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString());
84+
85+
var fullGameDir = gameDir is null ? null : FileSystem.Path.GetFullPath(gameDir);
86+
87+
var exists = FileExists(missing.AsSpan(), fullGameDir.AsSpan());
88+
89+
Assert.False(exists);
90+
}
91+
92+
[Fact]
93+
public void FileExists_RelativePathUnderGameDirectory_ReturnsTrue()
94+
{
95+
var dir = NewTempDir();
96+
var file = FileSystem.Path.Combine(dir, "test.txt");
97+
FileSystem.File.WriteAllText(file, "x");
98+
99+
var sb = new ValueStringBuilder();
100+
var exists = PgFileSystem.FileExists("test.txt".AsSpan(), ref sb, dir.AsSpan());
101+
102+
Assert.True(exists);
103+
AssertResolvedPath(file, sb.ToString());
104+
}
105+
106+
[Fact]
107+
public void FileExists_GameDirectoryWithTrailingSeparator_ReturnsTrue()
108+
{
109+
var dir = NewTempDir();
110+
var dirWithTrailing = dir + FileSystem.Path.DirectorySeparatorChar;
111+
var file = FileSystem.Path.Combine(dir, "test.txt");
112+
FileSystem.File.WriteAllText(file, "x");
113+
114+
var sb = new ValueStringBuilder();
115+
var exists = PgFileSystem.FileExists("test.txt".AsSpan(), ref sb, dirWithTrailing.AsSpan());
116+
117+
Assert.True(exists);
118+
AssertResolvedPath(file, sb.ToString());
119+
}
120+
121+
[Fact]
122+
public void FileExists_MissingIntermediateDirectory_ReturnsFalse()
123+
{
124+
var dir = NewTempDir();
125+
// Create dir/a/c.txt — note: no "b" intermediate.
126+
FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "a"));
127+
FileSystem.File.WriteAllText(FileSystem.Path.Combine(dir, "a", "c.txt"), "x");
128+
129+
var exists = FileExists("a/b/c.txt".AsSpan(), dir.AsSpan());
130+
131+
Assert.False(exists);
132+
}
133+
134+
[Fact]
135+
public void FileExists_FullyQualifiedPathOutsideGameDirectory_ReturnsTrue()
136+
{
137+
var root = NewTempDir();
138+
var gameDir = FileSystem.Path.Combine(root, "game");
139+
var otherDir = FileSystem.Path.Combine(root, "other", "DATA");
140+
FileSystem.Directory.CreateDirectory(gameDir);
141+
FileSystem.Directory.CreateDirectory(otherDir);
142+
var file = FileSystem.Path.Combine(otherDir, "FILE.TXT");
143+
FileSystem.File.WriteAllText(file, "x");
144+
145+
// Pass a fully-qualified path with mismatched casing for the leaf; gameDir is unrelated.
146+
var input = FileSystem.Path.Combine(FileSystem.Path.GetDirectoryName(otherDir)!, "data", "file.txt");
147+
148+
var sb = new ValueStringBuilder();
149+
var exists = PgFileSystem.FileExists(input.AsSpan(), ref sb, gameDir.AsSpan());
150+
151+
Assert.True(exists);
152+
AssertResolvedPath(file, sb.ToString());
153+
}
154+
155+
[Fact]
156+
public void FileExists_DotSegmentInInputPath_ReturnsTrue()
157+
{
158+
var dir = NewTempDir();
159+
FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "DATA"));
160+
var file = FileSystem.Path.Combine(dir, "DATA", "FILE.TXT");
161+
FileSystem.File.WriteAllText(file, "x");
162+
163+
// Leading "./" plus mismatched casing — the dispatcher must normalize the dot away.
164+
var sb = new ValueStringBuilder();
165+
var exists = PgFileSystem.FileExists(@".\DATA\file.txt".AsSpan(), ref sb, dir.AsSpan());
166+
167+
Assert.True(exists);
168+
AssertResolvedPath(file, sb.ToString());
169+
}
170+
171+
[Fact]
172+
public void FileExists_DotDotSegmentInInputPath_ReturnsTrue()
173+
{
174+
var dir = NewTempDir();
175+
FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "DATA"));
176+
var file = FileSystem.Path.Combine(dir, "DATA", "FILE.TXT");
177+
FileSystem.File.WriteAllText(file, "x");
178+
179+
// Other/.. cancels out so the dispatcher must resolve to dir/DATA/file.txt.
180+
var sb = new ValueStringBuilder();
181+
var exists = PgFileSystem.FileExists(@"Other\..\DATA\file.txt".AsSpan(), ref sb, dir.AsSpan());
182+
183+
Assert.True(exists);
184+
AssertResolvedPath(file, sb.ToString());
185+
}
186+
187+
[Theory]
188+
// Resolves to the game directory itself.
189+
[InlineData(".")]
190+
[InlineData("./")]
191+
[InlineData(@".\")]
192+
// Resolves to a subdirectory.
193+
[InlineData("Data")]
194+
[InlineData("Data/")]
195+
[InlineData(@"Data\")]
196+
[InlineData("Data/.")]
197+
[InlineData("Data/./")]
198+
[InlineData(@"Data\.\")]
199+
// Resolves to a deeper subdirectory (with case-folded variant).
200+
[InlineData("Data/foo")]
201+
[InlineData("DATA/FOO")]
202+
public void FileExists_PathResolvesToDirectory_ReturnsFalse(string input)
203+
{
204+
var dir = NewTempDir();
205+
FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "Data", "foo"));
206+
207+
var exists = FileExists(input.AsSpan(), dir.AsSpan());
208+
209+
Assert.False(exists);
210+
}
211+
212+
[Theory]
213+
[InlineData("test.txt", "TEST.txt")]
214+
[InlineData("dir/test.txt", "DIR/TEST.txt")]
215+
[InlineData("a/b/c.txt", "A/B/C.txt")]
216+
[InlineData("A/B/C.txt", "a/b/c.txt")]
217+
[InlineData("a/B/c.txt", "A/b/C.txt")]
218+
[InlineData("a/B/C.txt", "a/B/c.txt")]
219+
[InlineData("a/b/C/D.txt", "a/b/c/d.txt")]
220+
public void FileExists_AnyCasing_ReturnsTrue(string inputPath, string actualPathOnDisk)
221+
{
222+
var dir = NewTempDir();
223+
var fullOnDisk = FileSystem.Path.Combine(dir, actualPathOnDisk.Replace('/', FileSystem.Path.DirectorySeparatorChar));
224+
var fullOnDiskParent = FileSystem.Path.GetDirectoryName(fullOnDisk);
225+
if (fullOnDiskParent != null)
226+
FileSystem.Directory.CreateDirectory(fullOnDiskParent);
227+
FileSystem.File.WriteAllText(fullOnDisk, "x");
228+
229+
var sb = new ValueStringBuilder();
230+
var exists = PgFileSystem.FileExists(inputPath.AsSpan(), ref sb, dir.AsSpan());
231+
232+
Assert.True(exists);
233+
AssertResolvedPath(fullOnDisk, sb.ToString());
234+
}
235+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO.Abstractions;
4+
using PG.StarWarsGame.Engine.IO.FileExistStrategies;
5+
using PG.StarWarsGame.Engine.Utilities;
6+
7+
namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies;
8+
9+
internal sealed class TrackingFileExistsStrategy(IFileSystem fileSystem) : FileExistsStrategy(fileSystem)
10+
{
11+
public int CallCount { get; private set; }
12+
13+
public List<string> InvokedPaths { get; } = [];
14+
15+
public bool ReturnValue { get; set; }
16+
17+
public string? ResolvedPath { get; set; }
18+
19+
public override bool FileExists(ReadOnlySpan<char> gameDirectory, ref ValueStringBuilder stringBuilder)
20+
{
21+
CallCount++;
22+
InvokedPaths.Add(stringBuilder.AsSpan().ToString());
23+
if (ReturnValue && ResolvedPath is not null)
24+
{
25+
stringBuilder.Length = 0;
26+
stringBuilder.Append(ResolvedPath);
27+
}
28+
return ReturnValue;
29+
}
30+
}

0 commit comments

Comments
 (0)