Skip to content

Commit 02bcd4a

Browse files
committed
FileDiffServiceとFolderDiffServiceのテスト容易性を改善
1 parent 4316761 commit 02bcd4a

15 files changed

Lines changed: 919 additions & 60 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1111

1212
#### Changed
1313

14+
- Added `IFileSystemService` and `IFileComparisonService` as low-level seams for folder discovery/output I/O and per-file comparison I/O, making permission and disk-failure paths unit-testable without changing production behavior.
15+
- Split folder/file diff coverage more clearly into lightweight unit tests and temp-directory-backed integration tests, and expanded automated coverage for hash failures, IL-output failures, and large-text comparison paths.
16+
- Updated the README, developer guide, and testing guide in both English and Japanese to document the new service seams, test boundaries, and the latest passing test count (`218`).
1417
- Moved aggregated `MD5Mismatch` console warnings into `ProgramRunner`, kept `ReportGenerateService` report-only, and updated related docs and automated tests.
1518
- Replaced one-off `string.Format(...)` usage with interpolated strings, removed broad `#region` usage, and deleted now-unused format/message constants.
1619
- Updated the developer and testing guides to reflect the current source-style expectations and latest passing test count.
@@ -208,6 +211,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
208211

209212
#### 変更
210213

214+
- `IFileSystemService``IFileComparisonService` を追加し、フォルダ列挙/出力系 I/O とファイル単位比較 I/O の差し替え口を明確にしました。これにより、本番挙動を変えずに権限エラーやディスク系失敗をユニットテストできるようにしました。
215+
- `FolderDiffService` / `FileDiffService` まわりのテストを、軽量ユニットテストと temp ディレクトリ前提の統合テストにより明確に分離し、ハッシュ失敗、IL 出力失敗、大きいテキスト比較経路の自動テストを拡充しました。
216+
- README、開発者ガイド、テストガイドの日英両記述を更新し、新しいサービス境界、テスト境界、最新の通過テスト件数(`218` 件)を反映しました。
211217
- 集約後の `MD5Mismatch` コンソール警告を `ProgramRunner` に移し、`ReportGenerateService` はレポート専用の責務に整理しました。あわせて関連ドキュメントと自動テストを更新しました。
212218
- 単発利用の `string.Format(...)` を補間文字列へ置き換え、広範な `#region` 利用をやめ、不要になった書式・メッセージ定数を削除しました。
213219
- 開発ガイドとテストガイドを更新し、現在のソースコード方針と最新の通過テスト件数を反映しました。

FolderDiffIL4DotNet.Tests/Services/FileDiffServiceTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
namespace FolderDiffIL4DotNet.Tests.Services
1414
{
15+
[Trait("Category", "Integration")]
1516
public sealed class FileDiffServiceTests : IDisposable
1617
{
1718
private readonly string _rootDir;
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using FolderDiffIL4DotNet.Models;
8+
using FolderDiffIL4DotNet.Services;
9+
using FolderDiffIL4DotNet.Utils;
10+
using Xunit;
11+
12+
namespace FolderDiffIL4DotNet.Tests.Services
13+
{
14+
[Trait("Category", "Unit")]
15+
public sealed class FileDiffServiceUnitTests
16+
{
17+
[Fact]
18+
public async Task FilesAreEqualAsync_WhenHashMatches_ReturnsTrueAndShortCircuitsFurtherWork()
19+
{
20+
var fileComparisonService = new FakeFileComparisonService
21+
{
22+
HashResult = true
23+
};
24+
var ilOutputService = new FakeILOutputService();
25+
var resultLists = new FileDiffResultLists();
26+
var logger = new TestLogger();
27+
var service = CreateService(fileComparisonService, ilOutputService, resultLists, logger);
28+
29+
var areEqual = await service.FilesAreEqualAsync("sample.txt", maxParallel: 4);
30+
31+
Assert.True(areEqual);
32+
Assert.Equal(FileDiffResultLists.DiffDetailResult.MD5Match, resultLists.FileRelativePathToDiffDetailDictionary["sample.txt"]);
33+
Assert.Single(fileComparisonService.HashCalls);
34+
Assert.Empty(fileComparisonService.DotNetDetectionCalls);
35+
Assert.Empty(fileComparisonService.TextDiffCalls);
36+
Assert.Empty(fileComparisonService.ReadChunkCalls);
37+
Assert.Empty(ilOutputService.DiffCalls);
38+
}
39+
40+
[Fact]
41+
public async Task FilesAreEqualAsync_WhenHashDiffThrowsUnauthorizedAccessException_LogsErrorAndRethrows()
42+
{
43+
var fileComparisonService = new FakeFileComparisonService
44+
{
45+
HashException = new UnauthorizedAccessException("permission denied")
46+
};
47+
var ilOutputService = new FakeILOutputService();
48+
var resultLists = new FileDiffResultLists();
49+
var logger = new TestLogger();
50+
var service = CreateService(fileComparisonService, ilOutputService, resultLists, logger);
51+
52+
var exception = await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.FilesAreEqualAsync("secret.bin"));
53+
54+
Assert.Equal("permission denied", exception.Message);
55+
Assert.Contains(
56+
logger.Entries,
57+
entry => entry.LogLevel == AppLogLevel.Error
58+
&& entry.Message.Contains("An error occurred while diffing", StringComparison.Ordinal));
59+
Assert.Empty(resultLists.FileRelativePathToDiffDetailDictionary);
60+
}
61+
62+
[Fact]
63+
public async Task FilesAreEqualAsync_WhenIlOutputThrowsIOException_LogsErrorAndRethrows()
64+
{
65+
var fileComparisonService = new FakeFileComparisonService
66+
{
67+
HashResult = false,
68+
DotNetDetectionResult = new DotNetExecutableDetectionResult(DotNetExecutableDetectionStatus.DotNetExecutable)
69+
};
70+
var ilOutputService = new FakeILOutputService
71+
{
72+
DiffException = new IOException("disk full")
73+
};
74+
var resultLists = new FileDiffResultLists();
75+
var logger = new TestLogger();
76+
var service = CreateService(fileComparisonService, ilOutputService, resultLists, logger);
77+
78+
var exception = await Assert.ThrowsAsync<IOException>(() => service.FilesAreEqualAsync("assembly.dll"));
79+
80+
Assert.Equal("disk full", exception.Message);
81+
Assert.Single(ilOutputService.DiffCalls);
82+
Assert.Contains(
83+
logger.Entries,
84+
entry => entry.LogLevel == AppLogLevel.Error
85+
&& entry.Message.Contains("An error occurred while diffing", StringComparison.Ordinal));
86+
Assert.Empty(resultLists.FileRelativePathToDiffDetailDictionary);
87+
}
88+
89+
[Fact]
90+
public async Task FilesAreEqualAsync_WhenLargeTextFilesMatch_UsesParallelChunkComparison()
91+
{
92+
const string relativePath = "large.txt";
93+
var fileComparisonService = new FakeFileComparisonService
94+
{
95+
HashResult = false,
96+
DotNetDetectionResult = new DotNetExecutableDetectionResult(DotNetExecutableDetectionStatus.NotDotNetExecutable)
97+
};
98+
var oldFileAbsolutePath = Path.Combine("/virtual/old", relativePath);
99+
var newFileAbsolutePath = Path.Combine("/virtual/new", relativePath);
100+
fileComparisonService.SetFileContent(oldFileAbsolutePath, new string('A', 2048));
101+
fileComparisonService.SetFileContent(newFileAbsolutePath, new string('A', 2048));
102+
103+
var ilOutputService = new FakeILOutputService();
104+
var resultLists = new FileDiffResultLists();
105+
var logger = new TestLogger();
106+
var service = CreateService(
107+
fileComparisonService,
108+
ilOutputService,
109+
resultLists,
110+
logger,
111+
optimizeForNetworkShares: false,
112+
configure: config =>
113+
{
114+
config.TextDiffParallelThresholdKilobytes = 1;
115+
config.TextDiffChunkSizeKilobytes = 1;
116+
});
117+
118+
var areEqual = await service.FilesAreEqualAsync(relativePath, maxParallel: 2);
119+
120+
Assert.True(areEqual);
121+
Assert.Equal(FileDiffResultLists.DiffDetailResult.TextMatch, resultLists.FileRelativePathToDiffDetailDictionary[relativePath]);
122+
Assert.NotEmpty(fileComparisonService.ReadChunkCalls);
123+
Assert.Empty(fileComparisonService.TextDiffCalls);
124+
}
125+
126+
[Fact]
127+
public async Task FilesAreEqualAsync_WhenSequentialTextCompareThrowsUnauthorizedAccessException_LogsWarningThenErrorAndRethrows()
128+
{
129+
const string relativePath = "locked.txt";
130+
var fileComparisonService = new FakeFileComparisonService
131+
{
132+
HashResult = false,
133+
DotNetDetectionResult = new DotNetExecutableDetectionResult(DotNetExecutableDetectionStatus.NotDotNetExecutable),
134+
TextDiffException = new UnauthorizedAccessException("text access denied")
135+
};
136+
var ilOutputService = new FakeILOutputService();
137+
var resultLists = new FileDiffResultLists();
138+
var logger = new TestLogger();
139+
var service = CreateService(
140+
fileComparisonService,
141+
ilOutputService,
142+
resultLists,
143+
logger,
144+
optimizeForNetworkShares: true);
145+
146+
var exception = await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.FilesAreEqualAsync(relativePath, maxParallel: 1));
147+
148+
Assert.Equal("text access denied", exception.Message);
149+
var warning = Assert.Single(logger.Entries, entry => entry.LogLevel == AppLogLevel.Warning);
150+
Assert.Contains("Falling back to sequential text diff", warning.Message);
151+
Assert.IsType<UnauthorizedAccessException>(warning.Exception);
152+
Assert.Contains(
153+
logger.Entries,
154+
entry => entry.LogLevel == AppLogLevel.Error
155+
&& entry.Message.Contains("An error occurred while diffing", StringComparison.Ordinal));
156+
Assert.Empty(resultLists.FileRelativePathToDiffDetailDictionary);
157+
}
158+
159+
private static FileDiffService CreateService(
160+
FakeFileComparisonService fileComparisonService,
161+
FakeILOutputService ilOutputService,
162+
FileDiffResultLists resultLists,
163+
TestLogger logger,
164+
bool optimizeForNetworkShares = false,
165+
Action<ConfigSettings> configure = null)
166+
{
167+
var config = new ConfigSettings
168+
{
169+
TextFileExtensions = new List<string> { ".txt" },
170+
IgnoredExtensions = new List<string>(),
171+
ShouldOutputILText = false,
172+
EnableILCache = false,
173+
OptimizeForNetworkShares = optimizeForNetworkShares,
174+
TextDiffParallelThresholdKilobytes = 512,
175+
TextDiffChunkSizeKilobytes = 64
176+
};
177+
configure?.Invoke(config);
178+
179+
var executionContext = new DiffExecutionContext(
180+
"/virtual/old",
181+
"/virtual/new",
182+
"/virtual/report",
183+
optimizeForNetworkShares: optimizeForNetworkShares,
184+
detectedNetworkOld: false,
185+
detectedNetworkNew: false);
186+
return new FileDiffService(config, ilOutputService, executionContext, resultLists, logger, fileComparisonService);
187+
}
188+
189+
private sealed class FakeFileComparisonService : IFileComparisonService
190+
{
191+
private readonly Dictionary<string, byte[]> _fileContentsByPath = new(StringComparer.OrdinalIgnoreCase);
192+
193+
public bool HashResult { get; set; }
194+
195+
public Exception HashException { get; set; }
196+
197+
public bool TextDiffResult { get; set; }
198+
199+
public Exception TextDiffException { get; set; }
200+
201+
public Exception ReadChunkException { get; set; }
202+
203+
public DotNetExecutableDetectionResult DotNetDetectionResult { get; set; } =
204+
new(DotNetExecutableDetectionStatus.NotDotNetExecutable);
205+
206+
public List<(string File1, string File2)> HashCalls { get; } = new();
207+
208+
public List<string> DotNetDetectionCalls { get; } = new();
209+
210+
public List<(string File1, string File2)> TextDiffCalls { get; } = new();
211+
212+
public List<(string Path, long Offset, int Length)> ReadChunkCalls { get; } = new();
213+
214+
public void SetFileContent(string path, string content)
215+
=> _fileContentsByPath[path] = System.Text.Encoding.UTF8.GetBytes(content);
216+
217+
public Task<bool> DiffFilesByHashAsync(string file1AbsolutePath, string file2AbsolutePath)
218+
{
219+
HashCalls.Add((file1AbsolutePath, file2AbsolutePath));
220+
if (HashException != null)
221+
{
222+
throw HashException;
223+
}
224+
return Task.FromResult(HashResult);
225+
}
226+
227+
public Task<bool> DiffTextFilesAsync(string file1AbsolutePath, string file2AbsolutePath)
228+
{
229+
TextDiffCalls.Add((file1AbsolutePath, file2AbsolutePath));
230+
if (TextDiffException != null)
231+
{
232+
throw TextDiffException;
233+
}
234+
return Task.FromResult(TextDiffResult);
235+
}
236+
237+
public DotNetExecutableDetectionResult DetectDotNetExecutable(string fileAbsolutePath)
238+
{
239+
DotNetDetectionCalls.Add(fileAbsolutePath);
240+
return DotNetDetectionResult;
241+
}
242+
243+
public bool FileExists(string fileAbsolutePath)
244+
=> _fileContentsByPath.ContainsKey(fileAbsolutePath);
245+
246+
public long GetFileLength(string fileAbsolutePath)
247+
{
248+
if (_fileContentsByPath.TryGetValue(fileAbsolutePath, out var content))
249+
{
250+
return content.LongLength;
251+
}
252+
253+
throw new FileNotFoundException($"File not found: {fileAbsolutePath}", fileAbsolutePath);
254+
}
255+
256+
public Task<int> ReadChunkAsync(string fileAbsolutePath, long offset, Memory<byte> buffer, CancellationToken cancellationToken)
257+
{
258+
ReadChunkCalls.Add((fileAbsolutePath, offset, buffer.Length));
259+
if (ReadChunkException != null)
260+
{
261+
throw ReadChunkException;
262+
}
263+
if (!_fileContentsByPath.TryGetValue(fileAbsolutePath, out var content))
264+
{
265+
throw new FileNotFoundException($"File not found: {fileAbsolutePath}", fileAbsolutePath);
266+
}
267+
268+
int start = checked((int)offset);
269+
if (start >= content.Length)
270+
{
271+
return Task.FromResult(0);
272+
}
273+
274+
int count = Math.Min(buffer.Length, content.Length - start);
275+
content.AsMemory(start, count).CopyTo(buffer);
276+
return Task.FromResult(count);
277+
}
278+
}
279+
280+
private sealed class FakeILOutputService : IILOutputService
281+
{
282+
public (bool AreEqual, string DisassemblerLabel) DiffResult { get; set; }
283+
284+
public Exception DiffException { get; set; }
285+
286+
public List<DiffCall> DiffCalls { get; } = new();
287+
288+
public Task PrecomputeAsync(IEnumerable<string> filesAbsolutePaths, int maxParallel)
289+
=> Task.CompletedTask;
290+
291+
public Task<(bool AreEqual, string DisassemblerLabel)> DiffDotNetAssembliesAsync(string fileRelativePath, string oldFolderAbsolutePath, string newFolderAbsolutePath, bool shouldOutputIlText)
292+
{
293+
DiffCalls.Add(new DiffCall(fileRelativePath, oldFolderAbsolutePath, newFolderAbsolutePath, shouldOutputIlText));
294+
if (DiffException != null)
295+
{
296+
throw DiffException;
297+
}
298+
return Task.FromResult(DiffResult);
299+
}
300+
}
301+
302+
private sealed class TestLogger : ILoggerService
303+
{
304+
public string LogFileAbsolutePath => null;
305+
306+
public List<LogEntry> Entries { get; } = new();
307+
308+
public void Initialize()
309+
{
310+
}
311+
312+
public void CleanupOldLogFiles(int maxLogGenerations)
313+
{
314+
}
315+
316+
public void LogMessage(AppLogLevel logLevel, string message, bool shouldOutputMessageToConsole, Exception exception = null)
317+
=> LogMessage(logLevel, message, shouldOutputMessageToConsole, consoleForegroundColor: null, exception);
318+
319+
public void LogMessage(AppLogLevel logLevel, string message, bool shouldOutputMessageToConsole, ConsoleColor? consoleForegroundColor, Exception exception = null)
320+
=> Entries.Add(new LogEntry(logLevel, message, exception));
321+
}
322+
323+
private sealed record DiffCall(string FileRelativePath, string OldFolderAbsolutePath, string NewFolderAbsolutePath, bool ShouldOutputIlText);
324+
325+
private sealed record LogEntry(AppLogLevel LogLevel, string Message, Exception Exception);
326+
}
327+
}

FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
namespace FolderDiffIL4DotNet.Tests.Services
1212
{
13+
[Trait("Category", "Integration")]
1314
public sealed class FolderDiffServiceTests : IDisposable
1415
{
1516
private readonly string _rootDir;

0 commit comments

Comments
 (0)