@@ -234,6 +234,149 @@ describe('sentinel', () => {
234234 } ) ;
235235 } ) ;
236236
237+ describe ( 'slot range validation' , ( ) => {
238+ let validator : EthAddress ;
239+
240+ beforeEach ( ( ) => {
241+ validator = EthAddress . random ( ) ;
242+ jest . spyOn ( store , 'getHistoryLength' ) . mockReturnValue ( 10 ) ;
243+ jest . spyOn ( store , 'getHistory' ) . mockResolvedValue ( [
244+ { slot : 1n , status : 'block-mined' } ,
245+ { slot : 2n , status : 'attestation-sent' } ,
246+ ] ) ;
247+ jest . spyOn ( store , 'getHistories' ) . mockResolvedValue ( {
248+ [ validator . toString ( ) ] : [
249+ { slot : 1n , status : 'block-mined' } ,
250+ { slot : 2n , status : 'attestation-sent' } ,
251+ ] ,
252+ } ) ;
253+ } ) ;
254+
255+ describe ( 'getValidatorStats' , ( ) => {
256+ it ( 'should throw when slot range exceeds history length' , async ( ) => {
257+ await expect ( sentinel . getValidatorStats ( validator , 1n , 16n ) ) . rejects . toThrow (
258+ 'Slot range (15) exceeds history length (10). Requested range: 1 to 16.' ,
259+ ) ;
260+ } ) ;
261+
262+ it ( 'should not throw when slot range equals history length' , async ( ) => {
263+ await expect ( sentinel . getValidatorStats ( validator , 1n , 11n ) ) . resolves . toBeDefined ( ) ;
264+ } ) ;
265+
266+ it ( 'should not throw when slot range is less than history length' , async ( ) => {
267+ await expect ( sentinel . getValidatorStats ( validator , 1n , 6n ) ) . resolves . toBeDefined ( ) ;
268+ } ) ;
269+
270+ it ( 'should return undefined when validator has no history' , async ( ) => {
271+ jest . spyOn ( store , 'getHistory' ) . mockResolvedValue ( undefined ) ;
272+ const result = await sentinel . getValidatorStats ( validator , 1n , 6n ) ;
273+ expect ( result ) . toBeUndefined ( ) ;
274+ } ) ;
275+
276+ it ( 'should return undefined when validator has empty history' , async ( ) => {
277+ jest . spyOn ( store , 'getHistory' ) . mockResolvedValue ( [ ] ) ;
278+ const result = await sentinel . getValidatorStats ( validator , 1n , 6n ) ;
279+ expect ( result ) . toBeUndefined ( ) ;
280+ } ) ;
281+
282+ it ( 'should return expected mocked data structure' , async ( ) => {
283+ const mockHistory : ValidatorStatusHistory = [
284+ { slot : 1n , status : 'block-mined' } ,
285+ { slot : 2n , status : 'attestation-sent' } ,
286+ ] ;
287+ const mockProvenPerformance = [
288+ { epoch : 1n , missed : 2 , total : 10 } ,
289+ { epoch : 2n , missed : 1 , total : 8 } ,
290+ ] ;
291+
292+ jest . spyOn ( store , 'getHistory' ) . mockResolvedValue ( mockHistory ) ;
293+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( mockProvenPerformance ) ;
294+ jest . spyOn ( sentinel , 'computeStatsForValidator' ) . mockReturnValue ( {
295+ address : validator ,
296+ totalSlots : 2 ,
297+ missedProposals : { count : 0 , currentStreak : 0 , rate : 0 } ,
298+ missedAttestations : { count : 0 , currentStreak : 0 , rate : 0 } ,
299+ history : mockHistory ,
300+ } ) ;
301+
302+ const result = await sentinel . getValidatorStats ( validator , 1n , 6n ) ;
303+
304+ expect ( result ) . toEqual ( {
305+ validator : {
306+ address : validator ,
307+ totalSlots : 2 ,
308+ missedProposals : { count : 0 , currentStreak : 0 , rate : 0 } ,
309+ missedAttestations : { count : 0 , currentStreak : 0 , rate : 0 } ,
310+ history : mockHistory ,
311+ } ,
312+ allTimeProvenPerformance : mockProvenPerformance ,
313+ lastProcessedSlot : sentinel . getLastProcessedSlot ( ) ,
314+ initialSlot : sentinel . getInitialSlot ( ) ,
315+ slotWindow : 10 ,
316+ } ) ;
317+ } ) ;
318+
319+ it ( 'should call computeStatsForValidator with correct parameters' , async ( ) => {
320+ const mockHistory : ValidatorStatusHistory = [ { slot : 5n , status : 'block-mined' } ] ;
321+ jest . spyOn ( store , 'getHistory' ) . mockResolvedValue ( mockHistory ) ;
322+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( [ ] ) ;
323+ const computeStatsSpy = jest . spyOn ( sentinel , 'computeStatsForValidator' ) . mockReturnValue ( {
324+ address : validator ,
325+ totalSlots : 1 ,
326+ missedProposals : { count : 0 , currentStreak : 0 , rate : 0 } ,
327+ missedAttestations : { count : 0 , currentStreak : 0 , rate : 0 } ,
328+ history : mockHistory ,
329+ } ) ;
330+
331+ await sentinel . getValidatorStats ( validator , 3n , 8n ) ;
332+
333+ expect ( computeStatsSpy ) . toHaveBeenCalledWith ( validator . toString ( ) , mockHistory , 3n , 8n ) ;
334+ } ) ;
335+
336+ it ( 'should use default slot range when not provided' , async ( ) => {
337+ const mockHistory : ValidatorStatusHistory = [ { slot : 5n , status : 'block-mined' } ] ;
338+ jest . spyOn ( store , 'getHistory' ) . mockResolvedValue ( mockHistory ) ;
339+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( [ ] ) ;
340+ const computeStatsSpy = jest . spyOn ( sentinel , 'computeStatsForValidator' ) . mockReturnValue ( {
341+ address : validator ,
342+ totalSlots : 1 ,
343+ missedProposals : { count : 0 , currentStreak : 0 , rate : 0 } ,
344+ missedAttestations : { count : 0 , currentStreak : 0 , rate : 0 } ,
345+ history : mockHistory ,
346+ } ) ;
347+
348+ await sentinel . getValidatorStats ( validator ) ;
349+
350+ expect ( computeStatsSpy ) . toHaveBeenCalledWith ( validator . toString ( ) , mockHistory , slot - BigInt ( 10 ) , slot ) ;
351+ } ) ;
352+
353+ it ( 'should return proven performance data from store' , async ( ) => {
354+ const mockHistory : ValidatorStatusHistory = [ { slot : 1n , status : 'block-mined' } ] ;
355+ const mockProvenPerformance = [
356+ { epoch : 5n , missed : 3 , total : 12 } ,
357+ { epoch : 6n , missed : 0 , total : 15 } ,
358+ ] ;
359+
360+ jest . spyOn ( store , 'getHistory' ) . mockResolvedValue ( mockHistory ) ;
361+ const getProvenPerformanceSpy = jest
362+ . spyOn ( store , 'getProvenPerformance' )
363+ . mockResolvedValue ( mockProvenPerformance ) ;
364+ jest . spyOn ( sentinel , 'computeStatsForValidator' ) . mockReturnValue ( {
365+ address : validator ,
366+ totalSlots : 1 ,
367+ missedProposals : { count : 0 , currentStreak : 0 , rate : 0 } ,
368+ missedAttestations : { count : 0 , currentStreak : 0 , rate : 0 } ,
369+ history : mockHistory ,
370+ } ) ;
371+
372+ const result = await sentinel . getValidatorStats ( validator ) ;
373+
374+ expect ( getProvenPerformanceSpy ) . toHaveBeenCalledWith ( validator ) ;
375+ expect ( result ?. allTimeProvenPerformance ) . toEqual ( mockProvenPerformance ) ;
376+ } ) ;
377+ } ) ;
378+ } ) ;
379+
237380 describe ( 'handleChainProven' , ( ) => {
238381 it ( 'calls inactivity watcher with performance data' , async ( ) => {
239382 const blockNumber = 15 ;
@@ -423,4 +566,16 @@ class TestSentinel extends Sentinel {
423566 public override updateProvenPerformance ( epoch : bigint , performance : ValidatorsEpochPerformance ) {
424567 return super . updateProvenPerformance ( epoch , performance ) ;
425568 }
569+
570+ public override getValidatorStats ( validatorAddress : EthAddress , fromSlot ?: bigint , toSlot ?: bigint ) {
571+ return super . getValidatorStats ( validatorAddress , fromSlot , toSlot ) ;
572+ }
573+
574+ public getLastProcessedSlot ( ) {
575+ return this . lastProcessedSlot ;
576+ }
577+
578+ public getInitialSlot ( ) {
579+ return this . initialSlot ;
580+ }
426581}
0 commit comments