Skip to content

Commit 9bf96be

Browse files
Add exact CLI workflow tests and update MS2Lib with Linux fix
- Tests replicate the exact MS2Create/MS2Extract CLI code path (concurrent Task.Run file addition, unsorted file listing, subfolder extraction) for determinism and round-trip verification - Update MS2Lib submodule with Linux named memory-mapped file fix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b2cdf44 commit 9bf96be

2 files changed

Lines changed: 236 additions & 74 deletions

File tree

MS2Lib

MS2Tools.IntegrationTests/ServerArchiveTests.cs

Lines changed: 235 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,33 @@ public static void ClassCleanup()
3939
}
4040
}
4141

42-
private static (string FullPath, string RelativePath)[] GetFilesRelative(string path)
42+
#region Helpers matching exact CLI code
43+
44+
/// <summary>
45+
/// Exact copy of MS2Create.Program.GetFilesRelative — no sorting, uses string.Remove extension.
46+
/// </summary>
47+
private static (string FullPath, string RelativePath)[] GetFilesRelativeCli(string path)
48+
{
49+
if (!path.EndsWith(Path.DirectorySeparatorChar))
50+
{
51+
path += Path.DirectorySeparatorChar;
52+
}
53+
54+
string[] files = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories);
55+
var result = new (string FullPath, string RelativePath)[files.Length];
56+
57+
for (int i = 0; i < files.Length; i++)
58+
{
59+
result[i] = (files[i], files[i].Remove(path));
60+
}
61+
62+
return result;
63+
}
64+
65+
/// <summary>
66+
/// Sorted variant for deterministic comparison tests.
67+
/// </summary>
68+
private static (string FullPath, string RelativePath)[] GetFilesRelativeSorted(string path)
4369
{
4470
if (!path.EndsWith(Path.DirectorySeparatorChar))
4571
{
@@ -67,7 +93,194 @@ private static CompressionType GetCompressionTypeFromFileExtension(string filePa
6793
_ => CompressionType.Zlib,
6894
};
6995

70-
private static async Task<(string headerPath, string dataPath)> PackageServerFolder(string outputSubDir)
96+
/// <summary>
97+
/// Exact copy of MS2Create.Program.AddAndCreateFileToArchive
98+
/// </summary>
99+
private static void AddAndCreateFileToArchive(IMS2Archive archive, (string fullPath, string relativePath)[] filePaths, uint index)
100+
{
101+
(string filePath, string relativePath) = filePaths[index];
102+
103+
uint id = index + 1;
104+
FileStream fsFile = File.OpenRead(filePath);
105+
IMS2FileInfo info = new MS2FileInfo(id.ToString(), relativePath);
106+
IMS2FileHeader header = new MS2FileHeader(fsFile.Length, id, 0, GetCompressionTypeFromFileExtension(filePath));
107+
IMS2File file = new MS2File(archive, fsFile, info, header, false);
108+
109+
archive.Add(file);
110+
}
111+
112+
/// <summary>
113+
/// Exact copy of MS2Create.Program.CreateArchive — concurrent Task.Run, no sorting.
114+
/// </summary>
115+
private static async Task<(string headerPath, string dataPath)> PackageServerFolderExactCli(string outputSubDir)
116+
{
117+
string outputPath = Path.Combine(TestOutputDir, outputSubDir);
118+
Directory.CreateDirectory(outputPath);
119+
120+
string headerPath = Path.Combine(outputPath, Path.ChangeExtension(ArchiveName, "m2h"));
121+
string dataPath = Path.Combine(outputPath, Path.ChangeExtension(ArchiveName, "m2d"));
122+
123+
var filePaths = GetFilesRelativeCli(ServerSourcePath);
124+
IMS2Archive archive = new MS2Archive(Repositories.Repos[MS2CryptoMode.MS2F]);
125+
126+
// Exact same concurrent pattern as MS2Create
127+
var tasks = new Task[filePaths.Length];
128+
for (uint i = 0; i < filePaths.Length; i++)
129+
{
130+
uint ic = i;
131+
tasks[i] = Task.Run(() => AddAndCreateFileToArchive(archive, filePaths, ic));
132+
}
133+
134+
await Task.WhenAll(tasks);
135+
136+
await archive.SaveConcurrentlyAsync(headerPath, dataPath);
137+
138+
return (headerPath, dataPath);
139+
}
140+
141+
/// <summary>
142+
/// Exact copy of MS2Extract extraction logic.
143+
/// Creates a subfolder named after the archive, just like the CLI does.
144+
/// </summary>
145+
private static async Task ExtractArchiveExactCli(string headerFile, string dataFile, string destinationPath)
146+
{
147+
// MS2Extract creates: destinationPath/archiveName/
148+
string dstPath = Path.Combine(destinationPath, Path.GetFileNameWithoutExtension(headerFile));
149+
Directory.CreateDirectory(dstPath);
150+
151+
using IMS2Archive archive = await MS2Archive.GetAndLoadArchiveAsync(headerFile, dataFile);
152+
153+
foreach (IMS2File file in archive)
154+
{
155+
if (string.IsNullOrWhiteSpace(file.Name))
156+
{
157+
continue;
158+
}
159+
160+
string fileDestinationPath = Path.Combine(dstPath, file.Name);
161+
await using Stream stream = await file.GetStreamAsync();
162+
await stream.CopyToAsync(fileDestinationPath);
163+
}
164+
}
165+
166+
private static string ComputeFileHash(string filePath)
167+
{
168+
using var stream = File.OpenRead(filePath);
169+
byte[] hash = SHA256.HashData(stream);
170+
return Convert.ToHexString(hash);
171+
}
172+
173+
#endregion
174+
175+
#region Exact CLI workflow tests
176+
177+
[TestMethod]
178+
[Timeout(300000)]
179+
public async Task CliWorkflow_CreateThenExtract_FilesMatchOriginals()
180+
{
181+
// Step 1: MS2Create ./server ./out server MS2F
182+
var (headerPath, dataPath) = await PackageServerFolderExactCli("cli_roundtrip");
183+
184+
Assert.IsTrue(File.Exists(headerPath), "Header file should exist");
185+
Assert.IsTrue(File.Exists(dataPath), "Data file should exist");
186+
Assert.IsTrue(new FileInfo(headerPath).Length > 0, "Header file should not be empty");
187+
Assert.IsTrue(new FileInfo(dataPath).Length > 0, "Data file should not be empty");
188+
189+
// Step 2: MS2Extract ./out/server.m2h ./extracted
190+
string extractDest = Path.Combine(TestOutputDir, "cli_extracted");
191+
await ExtractArchiveExactCli(headerPath, dataPath, extractDest);
192+
193+
// MS2Extract creates: cli_extracted/server/
194+
string extractedRoot = Path.Combine(extractDest, ArchiveName);
195+
Assert.IsTrue(Directory.Exists(extractedRoot), "Extracted subfolder should exist");
196+
197+
// Step 3: Verify every original file matches the extracted file
198+
var originalFiles = GetFilesRelativeCli(ServerSourcePath);
199+
int verifiedCount = 0;
200+
var mismatches = new List<string>();
201+
202+
foreach (var (fullPath, relativePath) in originalFiles)
203+
{
204+
string extractedPath = Path.Combine(extractedRoot, relativePath);
205+
206+
if (!File.Exists(extractedPath))
207+
{
208+
mismatches.Add($"MISSING: {relativePath}");
209+
continue;
210+
}
211+
212+
byte[] originalBytes = await File.ReadAllBytesAsync(fullPath);
213+
byte[] extractedBytes = await File.ReadAllBytesAsync(extractedPath);
214+
215+
if (!originalBytes.SequenceEqual(extractedBytes))
216+
{
217+
mismatches.Add($"CONTENT MISMATCH: {relativePath} (original={originalBytes.Length}b, extracted={extractedBytes.Length}b)");
218+
}
219+
220+
verifiedCount++;
221+
}
222+
223+
if (mismatches.Count > 0)
224+
{
225+
Assert.Fail($"Found {mismatches.Count} issue(s):\n{string.Join("\n", mismatches)}");
226+
}
227+
228+
Assert.AreEqual(originalFiles.Length, verifiedCount,
229+
$"Should verify all {originalFiles.Length} files");
230+
}
231+
232+
[TestMethod]
233+
[Timeout(300000)]
234+
public async Task CliWorkflow_CreateMultipleTimes_DeterministicOutput()
235+
{
236+
// Run the exact CLI packaging 3 times
237+
var (h1, d1) = await PackageServerFolderExactCli("cli_det_run1");
238+
string hHash1 = ComputeFileHash(h1);
239+
string dHash1 = ComputeFileHash(d1);
240+
241+
var (h2, d2) = await PackageServerFolderExactCli("cli_det_run2");
242+
string hHash2 = ComputeFileHash(h2);
243+
string dHash2 = ComputeFileHash(d2);
244+
245+
var (h3, d3) = await PackageServerFolderExactCli("cli_det_run3");
246+
string hHash3 = ComputeFileHash(h3);
247+
string dHash3 = ComputeFileHash(d3);
248+
249+
// Log sizes for debugging
250+
Console.WriteLine($"Run 1: header={new FileInfo(h1).Length}b data={new FileInfo(d1).Length}b");
251+
Console.WriteLine($"Run 2: header={new FileInfo(h2).Length}b data={new FileInfo(d2).Length}b");
252+
Console.WriteLine($"Run 3: header={new FileInfo(h3).Length}b data={new FileInfo(d3).Length}b");
253+
Console.WriteLine($"Header hashes: {hHash1} | {hHash2} | {hHash3}");
254+
Console.WriteLine($"Data hashes: {dHash1} | {dHash2} | {dHash3}");
255+
256+
Assert.AreEqual(hHash1, hHash2,
257+
$"Header hash mismatch between run 1 and 2.\nRun1: {hHash1}\nRun2: {hHash2}");
258+
Assert.AreEqual(dHash1, dHash2,
259+
$"Data hash mismatch between run 1 and 2.\nRun1: {dHash1}\nRun2: {dHash2}");
260+
Assert.AreEqual(hHash1, hHash3,
261+
$"Header hash mismatch between run 1 and 3.\nRun1: {hHash1}\nRun3: {hHash3}");
262+
Assert.AreEqual(dHash1, dHash3,
263+
$"Data hash mismatch between run 1 and 3.\nRun1: {dHash1}\nRun3: {dHash3}");
264+
}
265+
266+
[TestMethod]
267+
[Timeout(300000)]
268+
public async Task CliWorkflow_Create_ArchiveFileCountMatchesSource()
269+
{
270+
var (headerPath, dataPath) = await PackageServerFolderExactCli("cli_count");
271+
272+
using IMS2Archive archive = await MS2Archive.GetAndLoadArchiveAsync(headerPath, dataPath);
273+
274+
var sourceFiles = GetFilesRelativeCli(ServerSourcePath);
275+
Assert.AreEqual(sourceFiles.Length, (int)archive.Count,
276+
$"Archive should contain {sourceFiles.Length} files, but has {archive.Count}");
277+
}
278+
279+
#endregion
280+
281+
#region Original sequential tests (for comparison)
282+
283+
private static async Task<(string headerPath, string dataPath)> PackageServerFolderSequential(string outputSubDir)
71284
{
72285
string outputPath = Path.Combine(TestOutputDir, outputSubDir);
73286
Directory.CreateDirectory(outputPath);
@@ -76,7 +289,7 @@ private static CompressionType GetCompressionTypeFromFileExtension(string filePa
76289
string dataPath = Path.Combine(outputPath, ArchiveName + DataFileExtension);
77290

78291
var archive = new MS2Archive(Repositories.Repos[MS2CryptoMode.MS2F]);
79-
var filePaths = GetFilesRelative(ServerSourcePath);
292+
var filePaths = GetFilesRelativeSorted(ServerSourcePath);
80293

81294
for (uint i = 0; i < filePaths.Length; i++)
82295
{
@@ -94,103 +307,52 @@ private static CompressionType GetCompressionTypeFromFileExtension(string filePa
94307
return (headerPath, dataPath);
95308
}
96309

97-
private static string ComputeFileHash(string filePath)
98-
{
99-
using var stream = File.OpenRead(filePath);
100-
byte[] hash = SHA256.HashData(stream);
101-
return Convert.ToHexString(hash);
102-
}
103-
104310
[TestMethod]
105-
[Timeout(300000)] // 5 min timeout for large archive
106-
public async Task Package_ServerFolder_ProducesDeterministicOutput()
311+
[Timeout(300000)]
312+
public async Task Sequential_ProducesDeterministicOutput()
107313
{
108-
// First run
109-
var (headerPath1, dataPath1) = await PackageServerFolder("run1");
110-
string headerHash1 = ComputeFileHash(headerPath1);
111-
string dataHash1 = ComputeFileHash(dataPath1);
112-
113-
Assert.IsTrue(File.Exists(headerPath1), "Header file should exist after first run");
114-
Assert.IsTrue(File.Exists(dataPath1), "Data file should exist after first run");
115-
Assert.IsTrue(new FileInfo(headerPath1).Length > 0, "Header file should not be empty");
116-
Assert.IsTrue(new FileInfo(dataPath1).Length > 0, "Data file should not be empty");
117-
118-
// Second run
119-
var (headerPath2, dataPath2) = await PackageServerFolder("run2");
120-
string headerHash2 = ComputeFileHash(headerPath2);
121-
string dataHash2 = ComputeFileHash(dataPath2);
122-
123-
Assert.AreEqual(headerHash1, headerHash2,
124-
$"Header hash mismatch between runs.\nRun1: {headerHash1}\nRun2: {headerHash2}");
125-
Assert.AreEqual(dataHash1, dataHash2,
126-
$"Data hash mismatch between runs.\nRun1: {dataHash1}\nRun2: {dataHash2}");
127-
128-
// Third run for extra confidence
129-
var (headerPath3, dataPath3) = await PackageServerFolder("run3");
130-
string headerHash3 = ComputeFileHash(headerPath3);
131-
string dataHash3 = ComputeFileHash(dataPath3);
132-
133-
Assert.AreEqual(headerHash1, headerHash3,
134-
$"Header hash mismatch on third run.\nRun1: {headerHash1}\nRun3: {headerHash3}");
135-
Assert.AreEqual(dataHash1, dataHash3,
136-
$"Data hash mismatch on third run.\nRun1: {dataHash1}\nRun3: {dataHash3}");
314+
var (h1, d1) = await PackageServerFolderSequential("seq_run1");
315+
string hHash1 = ComputeFileHash(h1);
316+
string dHash1 = ComputeFileHash(d1);
317+
318+
var (h2, d2) = await PackageServerFolderSequential("seq_run2");
319+
string hHash2 = ComputeFileHash(h2);
320+
string dHash2 = ComputeFileHash(d2);
321+
322+
Assert.AreEqual(hHash1, hHash2, $"Header hash mismatch.\nRun1: {hHash1}\nRun2: {hHash2}");
323+
Assert.AreEqual(dHash1, dHash2, $"Data hash mismatch.\nRun1: {dHash1}\nRun2: {dHash2}");
137324
}
138325

139326
[TestMethod]
140327
[Timeout(300000)]
141-
public async Task Package_ThenExtract_ServerFolder_FilesMatchOriginals()
328+
public async Task Sequential_ThenExtract_FilesMatchOriginals()
142329
{
143-
var (headerPath, dataPath) = await PackageServerFolder("extract_test");
330+
var (headerPath, dataPath) = await PackageServerFolderSequential("seq_extract");
144331

145-
string extractPath = Path.Combine(TestOutputDir, "extracted");
332+
string extractPath = Path.Combine(TestOutputDir, "seq_extracted");
146333
Directory.CreateDirectory(extractPath);
147334

148-
// Extract
149335
using (IMS2Archive archive = await MS2Archive.GetAndLoadArchiveAsync(headerPath, dataPath))
150336
{
151-
Assert.IsTrue(archive.Count > 0, "Archive should contain files");
152-
153337
foreach (var file in archive)
154338
{
155339
string destPath = Path.Combine(extractPath, file.Name);
156-
157340
using Stream stream = await file.GetStreamAsync();
158341
await stream.CopyToAsync(destPath);
159342
}
160343
}
161344

162-
// Verify extracted files match originals
163-
var originalFiles = GetFilesRelative(ServerSourcePath);
164-
int verifiedCount = 0;
165-
345+
var originalFiles = GetFilesRelativeSorted(ServerSourcePath);
166346
foreach (var (fullPath, relativePath) in originalFiles)
167347
{
168348
string extractedPath = Path.Combine(extractPath, relativePath);
169-
Assert.IsTrue(File.Exists(extractedPath),
170-
$"Extracted file missing: {relativePath}");
349+
Assert.IsTrue(File.Exists(extractedPath), $"Missing: {relativePath}");
171350

172351
byte[] originalBytes = await File.ReadAllBytesAsync(fullPath);
173352
byte[] extractedBytes = await File.ReadAllBytesAsync(extractedPath);
174-
175-
CollectionAssert.AreEqual(originalBytes, extractedBytes,
176-
$"Content mismatch for: {relativePath}");
177-
verifiedCount++;
353+
CollectionAssert.AreEqual(originalBytes, extractedBytes, $"Content mismatch: {relativePath}");
178354
}
179-
180-
Assert.AreEqual(originalFiles.Length, verifiedCount,
181-
"Number of verified files should match number of original files");
182355
}
183356

184-
[TestMethod]
185-
[Timeout(300000)]
186-
public async Task Package_ServerFolder_ArchiveFileCountMatchesSourceFileCount()
187-
{
188-
var (headerPath, dataPath) = await PackageServerFolder("count_test");
189-
190-
using IMS2Archive archive = await MS2Archive.GetAndLoadArchiveAsync(headerPath, dataPath);
191-
192-
var sourceFiles = GetFilesRelative(ServerSourcePath);
193-
Assert.AreEqual(sourceFiles.Length, (int)archive.Count,
194-
$"Archive should contain {sourceFiles.Length} files, but has {archive.Count}");
195-
}
357+
#endregion
196358
}

0 commit comments

Comments
 (0)