@@ -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.\n Run1: { hHash1 } \n Run2: { hHash2 } ") ;
258+ Assert . AreEqual ( dHash1 , dHash2 ,
259+ $ "Data hash mismatch between run 1 and 2.\n Run1: { dHash1 } \n Run2: { dHash2 } ") ;
260+ Assert . AreEqual ( hHash1 , hHash3 ,
261+ $ "Header hash mismatch between run 1 and 3.\n Run1: { hHash1 } \n Run3: { hHash3 } ") ;
262+ Assert . AreEqual ( dHash1 , dHash3 ,
263+ $ "Data hash mismatch between run 1 and 3.\n Run1: { dHash1 } \n Run3: { 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.\n Run1: { headerHash1 } \n Run2: { headerHash2 } ") ;
125- Assert . AreEqual ( dataHash1 , dataHash2 ,
126- $ "Data hash mismatch between runs.\n Run1: { dataHash1 } \n Run2: { 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.\n Run1: { headerHash1 } \n Run3: { headerHash3 } ") ;
135- Assert . AreEqual ( dataHash1 , dataHash3 ,
136- $ "Data hash mismatch on third run.\n Run1: { dataHash1 } \n Run3: { 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.\n Run1: { hHash1 } \n Run2: { hHash2 } ") ;
323+ Assert . AreEqual ( dHash1 , dHash2 , $ "Data hash mismatch.\n Run1: { dHash1 } \n Run2: { 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