@@ -1287,4 +1287,162 @@ public void WriteFromLibrarySpectra_PreservesFragmentMzValues()
12871287 Assert . That ( storedMz , Is . EqualTo ( ( float ) TargetMz ) . Within ( 1e-3f ) ,
12881288 "Fragment m/z must be preserved to within float32 precision through the conversion path." ) ;
12891289 }
1290+
1291+ // ────────────────────────────────────────────────────────────────────────
1292+ // Group: Fragment-count limit (PR #1079 — reject >short.MaxValue on write)
1293+ // ────────────────────────────────────────────────────────────────────────
1294+ //
1295+ // MslPrecursorRecord.FragmentCount is a signed 16-bit field, so an entry may
1296+ // hold at most short.MaxValue (32,767) fragment ions. Before this guard, a
1297+ // larger count was silently narrowed by an unchecked (short) cast: it wrapped
1298+ // to a negative value on write and only surfaced as an OverflowException
1299+ // (negative array length) later, on read. The guard moves that failure to
1300+ // write time, at the cause, on BOTH the in-memory (Write) and streaming
1301+ // (WriteStreaming) paths. These tests pin:
1302+ // • the over-limit case throws ArgumentException naming the entry + limit, and
1303+ // • the exact 32,767 boundary still writes unchanged (locks '>' vs '>=').
1304+
1305+ /// <summary>The per-entry fragment-ion limit imposed by the 16-bit on-disk field.</summary>
1306+ private const int MaxFragmentsPerEntry = short . MaxValue ; // 32,767
1307+
1308+ /// <summary>
1309+ /// Builds a single entry holding exactly <paramref name="fragmentCount"/> fragment ions,
1310+ /// used to exercise the write-time fragment-count guard at and beyond the int16 limit.
1311+ /// Fragment scalar fields (FragmentNumber, ResiduePosition) are kept within int16 range so
1312+ /// the only field able to overflow is FragmentCount itself — isolating the behaviour under test.
1313+ /// </summary>
1314+ /// <param name="fragmentCount">Number of fragment ions to attach to the entry.</param>
1315+ /// <param name="fullSequence">FullSequence used to identify the entry in the thrown message.</param>
1316+ /// <returns>A list containing exactly one entry with the requested fragment count.</returns>
1317+ private static List < MslLibraryEntry > BuildSingleEntryWithFragmentCount ( int fragmentCount , string fullSequence )
1318+ {
1319+ var fragments = new List < MslFragmentIon > ( fragmentCount ) ;
1320+ for ( int f = 0 ; f < fragmentCount ; f ++ )
1321+ {
1322+ fragments . Add ( new MslFragmentIon
1323+ {
1324+ Mz = 100f + f * 0.01f ,
1325+ Intensity = 1000f + ( f % 500 ) ,
1326+ ProductType = ProductType . y ,
1327+ SecondaryProductType = null ,
1328+ FragmentNumber = ( f % 1000 ) + 1 , // stays within int16 range
1329+ SecondaryFragmentNumber = 0 ,
1330+ ResiduePosition = ( f % 250 ) + 1 ,
1331+ Charge = 1 ,
1332+ NeutralLoss = 0.0 ,
1333+ ExcludeFromQuant = false
1334+ } ) ;
1335+ }
1336+
1337+ return new List < MslLibraryEntry >
1338+ {
1339+ new MslLibraryEntry
1340+ {
1341+ FullSequence = fullSequence ,
1342+ BaseSequence = "PEPTIDE" ,
1343+ PrecursorMz = 449.7 ,
1344+ ChargeState = 2 ,
1345+ Source = MslFormat . SourceType . Predicted ,
1346+ MoleculeType = MslFormat . MoleculeType . Peptide ,
1347+ DissociationType = DissociationType . HCD ,
1348+ MatchedFragmentIons = fragments
1349+ }
1350+ } ;
1351+ }
1352+
1353+ /// <summary>
1354+ /// In-memory path: an entry with exactly short.MaxValue (32,767) fragments must still write,
1355+ /// and the on-disk FragmentCount field must store 32,767 exactly (not a wrapped value). This
1356+ /// locks the guard's comparison as '>' rather than '>=' — i.e. the largest legal entry is
1357+ /// accepted, not rejected.
1358+ /// </summary>
1359+ [ Test ]
1360+ public void Write_AtMaxFragments_WritesAndStoresExactCount ( )
1361+ {
1362+ var entries = BuildSingleEntryWithFragmentCount ( MaxFragmentsPerEntry , "MAXFRAGS_PEPTIDE" ) ;
1363+ string path = TempPath ( nameof ( Write_AtMaxFragments_WritesAndStoresExactCount ) ) ;
1364+
1365+ Assert . That ( ( ) => MslWriter . Write ( path , entries ) , Throws . Nothing ,
1366+ "An entry with exactly 32,767 fragments is at the limit and must write successfully." ) ;
1367+
1368+ byte [ ] data = ReadAllBytes ( path ) ;
1369+ long precursorSectionOffset = ReadInt64LE ( data , HdrOffPrecursorSectionOffset ) ;
1370+
1371+ // MslPrecursorRecord.FragmentCount is at offset 14 from the start of the record (int16)
1372+ const int FragCountOffsetInRecord = 14 ;
1373+ short storedFragCount = ( short ) (
1374+ data [ ( int ) precursorSectionOffset + FragCountOffsetInRecord ]
1375+ | ( data [ ( int ) precursorSectionOffset + FragCountOffsetInRecord + 1 ] << 8 ) ) ;
1376+
1377+ Assert . That ( storedFragCount , Is . EqualTo ( ( short ) MaxFragmentsPerEntry ) ,
1378+ "At the 32,767 boundary the on-disk FragmentCount must equal 32,767 (no overflow wrap)." ) ;
1379+ }
1380+
1381+ /// <summary>
1382+ /// In-memory path: an entry one past the limit (32,768 fragments) must be rejected on write with
1383+ /// an ArgumentException whose message names the offending entry and states the 32,767 limit, rather
1384+ /// than silently wrapping to a negative count that only fails later on read.
1385+ /// </summary>
1386+ [ Test ]
1387+ public void Write_ExceedingMaxFragments_ThrowsArgumentExceptionNamingEntryAndLimit ( )
1388+ {
1389+ const string Seq = "OVERLIMIT_PEPTIDE" ;
1390+ var entries = BuildSingleEntryWithFragmentCount ( MaxFragmentsPerEntry + 1 , Seq ) ;
1391+ string path = TempPath ( nameof ( Write_ExceedingMaxFragments_ThrowsArgumentExceptionNamingEntryAndLimit ) ) ;
1392+
1393+ var ex = Assert . Throws < ArgumentException > ( ( ) => MslWriter . Write ( path , entries ) ,
1394+ "An entry with 32,768 fragments exceeds the int16 field and must throw on the in-memory write path." ) ;
1395+
1396+ Assert . That ( ex . Message , Does . Contain ( "32767" ) ,
1397+ "The exception message must state the per-entry limit (32,767)." ) ;
1398+ Assert . That ( ex . Message , Does . Contain ( Seq ) ,
1399+ "The exception message must name the offending entry by its FullSequence." ) ;
1400+ }
1401+
1402+ /// <summary>
1403+ /// Streaming path: the same guard must reject a 32,768-fragment entry, with the message naming the
1404+ /// entry and the 32,767 limit. The scope requires the guard on BOTH write paths; without this test a
1405+ /// regression that restored the unchecked (short) cast on only the streaming path would pass undetected.
1406+ /// </summary>
1407+ [ Test ]
1408+ public void WriteStreaming_ExceedingMaxFragments_ThrowsArgumentExceptionNamingEntryAndLimit ( )
1409+ {
1410+ const string Seq = "OVERLIMIT_STREAMING_PEPTIDE" ;
1411+ var entries = BuildSingleEntryWithFragmentCount ( MaxFragmentsPerEntry + 1 , Seq ) ;
1412+ string path = TempPath ( nameof ( WriteStreaming_ExceedingMaxFragments_ThrowsArgumentExceptionNamingEntryAndLimit ) ) ;
1413+
1414+ var ex = Assert . Throws < ArgumentException > ( ( ) => MslWriter . WriteStreaming ( path , entries ) ,
1415+ "An entry with 32,768 fragments must throw on the streaming write path, not just the in-memory path." ) ;
1416+
1417+ Assert . That ( ex . Message , Does . Contain ( "32767" ) ,
1418+ "The streaming-path exception message must state the per-entry limit (32,767)." ) ;
1419+ Assert . That ( ex . Message , Does . Contain ( Seq ) ,
1420+ "The streaming-path exception message must name the offending entry by its FullSequence." ) ;
1421+ }
1422+
1423+ /// <summary>
1424+ /// Streaming path: an entry with exactly 32,767 fragments must still write, and the streaming path
1425+ /// must store the same FragmentCount (32,767) as the in-memory path — confirming the two paths agree
1426+ /// at the boundary and neither wraps the count.
1427+ /// </summary>
1428+ [ Test ]
1429+ public void WriteStreaming_AtMaxFragments_WritesAndStoresExactCount ( )
1430+ {
1431+ var entries = BuildSingleEntryWithFragmentCount ( MaxFragmentsPerEntry , "MAXFRAGS_STREAMING_PEPTIDE" ) ;
1432+ string path = TempPath ( nameof ( WriteStreaming_AtMaxFragments_WritesAndStoresExactCount ) ) ;
1433+
1434+ Assert . That ( ( ) => MslWriter . WriteStreaming ( path , entries ) , Throws . Nothing ,
1435+ "An entry with exactly 32,767 fragments is at the limit and must stream successfully." ) ;
1436+
1437+ byte [ ] data = ReadAllBytes ( path ) ;
1438+ long precursorSectionOffset = ReadInt64LE ( data , HdrOffPrecursorSectionOffset ) ;
1439+
1440+ const int FragCountOffsetInRecord = 14 ;
1441+ short storedFragCount = ( short ) (
1442+ data [ ( int ) precursorSectionOffset + FragCountOffsetInRecord ]
1443+ | ( data [ ( int ) precursorSectionOffset + FragCountOffsetInRecord + 1 ] << 8 ) ) ;
1444+
1445+ Assert . That ( storedFragCount , Is . EqualTo ( ( short ) MaxFragmentsPerEntry ) ,
1446+ "At the 32,767 boundary the streaming path must store FragmentCount = 32,767 (no overflow wrap)." ) ;
1447+ }
12901448}
0 commit comments