@@ -361,14 +361,13 @@ describe('Flash detection (_analyzeWindow integration)', () => {
361361 } ) ;
362362
363363 it ( 'does NOT trigger at medium for 2 flash cycles but DOES trigger at high' , ( ) => {
364- // 2 flash cycles = 4 opposing transitions = ceil (4/2) = 2 flashes
364+ // 2 complete cycles: D→B→D→B→D (4 direction changes → floor (4/2)=2)
365365 const history = [
366366 { timestamp : 0 , luminance : 0.0 , redRatio : 0 } ,
367- { timestamp : 100 , luminance : 0.9 , redRatio : 0 } , // up
368- { timestamp : 200 , luminance : 0.0 , redRatio : 0 } , // down (transition 1)
369- { timestamp : 300 , luminance : 0.9 , redRatio : 0 } , // up (transition 2)
370- { timestamp : 400 , luminance : 0.0 , redRatio : 0 } , // down (transition 3)
371- { timestamp : 500 , luminance : 0.9 , redRatio : 0 } , // up (transition 4)
367+ { timestamp : 100 , luminance : 0.9 , redRatio : 0 } , // up (change 1)
368+ { timestamp : 200 , luminance : 0.0 , redRatio : 0 } , // down (change 2)
369+ { timestamp : 300 , luminance : 0.9 , redRatio : 0 } , // up (change 3)
370+ { timestamp : 400 , luminance : 0.0 , redRatio : 0 } , // down (change 4)
372371 ] ;
373372
374373 const medDet = createDetectorWithHistory ( 'medium' , history ) ;
@@ -382,6 +381,34 @@ describe('Flash detection (_analyzeWindow integration)', () => {
382381 `high should trigger at ${ highResult . flashCount } flashes` ) ;
383382 } ) ;
384383
384+ it ( 'triggers at maximum for a single complete flash cycle' , ( ) => {
385+ // 1 complete cycle: D→B→D (2 direction changes → floor(2/2)=1)
386+ const history = [
387+ { timestamp : 0 , luminance : 0.1 , redRatio : 0 } ,
388+ { timestamp : 67 , luminance : 0.8 , redRatio : 0 } ,
389+ { timestamp : 134 , luminance : 0.1 , redRatio : 0 } ,
390+ ] ;
391+ const det = createDetectorWithHistory ( 'maximum' , history ) ;
392+ const result = det . _analyzeWindow ( ) ;
393+ assert . strictEqual ( result . isFlashing , true ,
394+ `maximum should trigger at ${ result . flashCount } flash` ) ;
395+ assert . strictEqual ( result . flashCount , 1 ) ;
396+ } ) ;
397+
398+ it ( 'does NOT trigger at maximum for a single direction change (half-cycle)' , ( ) => {
399+ // Half cycle: D→B only (1 direction change → floor(1/2)=0)
400+ const history = [
401+ { timestamp : 0 , luminance : 0.1 , redRatio : 0 } ,
402+ { timestamp : 67 , luminance : 0.1 , redRatio : 0 } ,
403+ { timestamp : 134 , luminance : 0.8 , redRatio : 0 } ,
404+ ] ;
405+ const det = createDetectorWithHistory ( 'maximum' , history ) ;
406+ const result = det . _analyzeWindow ( ) ;
407+ assert . strictEqual ( result . isFlashing , false ,
408+ 'a single direction change is not a complete flash' ) ;
409+ assert . strictEqual ( result . flashCount , 0 ) ;
410+ } ) ;
411+
385412 it ( 'ignores luminance changes below the 0.1 threshold' , ( ) => {
386413 const history = [ ] ;
387414 for ( let i = 0 ; i < 20 ; i ++ ) {
@@ -507,6 +534,83 @@ describe('Flash detection (_analyzeWindow integration)', () => {
507534 } ) ;
508535} ) ;
509536
537+ describe ( 'realistic 15fps simulation' , ( ) => {
538+ it ( 'detects 3Hz flashing at medium when sampled at 15fps over full window' , ( ) => {
539+ // Simulate exactly what the real detector sees: 15fps sampling (67ms apart)
540+ // over the extended window (1067ms). A 3Hz signal alternates every ~167ms.
541+ const fps = 15 ;
542+ const interval = Math . round ( 1000 / fps ) ; // 67ms
543+ const windowMs = 1000 + interval ; // 1067ms
544+ const flashHz = 3 ;
545+ const halfPeriod = 1000 / ( flashHz * 2 ) ; // ~167ms per half-cycle
546+
547+ const history = [ ] ;
548+ for ( let t = 0 ; t <= windowMs ; t += interval ) {
549+ // Which half-cycle are we in? Even = dark, odd = bright.
550+ const phase = Math . floor ( t / halfPeriod ) ;
551+ history . push ( {
552+ timestamp : t ,
553+ luminance : phase % 2 === 0 ? 0.05 : 0.85 ,
554+ redRatio : 0 ,
555+ } ) ;
556+ }
557+
558+ const det = createDetectorWithHistory ( 'medium' , history ) ;
559+ const result = det . _analyzeWindow ( ) ;
560+ assert . strictEqual ( result . isFlashing , true ,
561+ `3Hz at medium should trigger (got ${ result . flashCount } flashes from ${ history . length } frames)` ) ;
562+ assert . ok ( result . flashCount >= 3 ,
563+ `expected >= 3 flashes, got ${ result . flashCount } ` ) ;
564+ } ) ;
565+
566+ it ( 'does NOT detect 2Hz flashing at medium (below 3Hz threshold)' , ( ) => {
567+ const fps = 15 ;
568+ const interval = Math . round ( 1000 / fps ) ;
569+ const windowMs = 1000 + interval ;
570+ const flashHz = 2 ;
571+ const halfPeriod = 1000 / ( flashHz * 2 ) ;
572+
573+ const history = [ ] ;
574+ for ( let t = 0 ; t <= windowMs ; t += interval ) {
575+ const phase = Math . floor ( t / halfPeriod ) ;
576+ history . push ( {
577+ timestamp : t ,
578+ luminance : phase % 2 === 0 ? 0.05 : 0.85 ,
579+ redRatio : 0 ,
580+ } ) ;
581+ }
582+
583+ const det = createDetectorWithHistory ( 'medium' , history ) ;
584+ const result = det . _analyzeWindow ( ) ;
585+ assert . strictEqual ( result . isFlashing , false ,
586+ `2Hz at medium should NOT trigger (got ${ result . flashCount } flashes)` ) ;
587+ } ) ;
588+
589+ it ( 'detects 1Hz flashing at maximum sensitivity' , ( ) => {
590+ const fps = 15 ;
591+ const interval = Math . round ( 1000 / fps ) ;
592+ const windowMs = 1000 + interval ;
593+ const flashHz = 1 ;
594+ const halfPeriod = 1000 / ( flashHz * 2 ) ;
595+
596+ const history = [ ] ;
597+ for ( let t = 0 ; t <= windowMs ; t += interval ) {
598+ const phase = Math . floor ( t / halfPeriod ) ;
599+ history . push ( {
600+ timestamp : t ,
601+ luminance : phase % 2 === 0 ? 0.05 : 0.85 ,
602+ redRatio : 0 ,
603+ } ) ;
604+ }
605+
606+ const det = createDetectorWithHistory ( 'maximum' , history ) ;
607+ const result = det . _analyzeWindow ( ) ;
608+ assert . strictEqual ( result . isFlashing , true ,
609+ `1Hz at maximum should trigger (got ${ result . flashCount } flashes)` ) ;
610+ assert . strictEqual ( result . flashCount , 1 ) ;
611+ } ) ;
612+ } ) ;
613+
510614describe ( 'FlashDetector class' , ( ) => {
511615 it ( 'defaults to medium sensitivity' , ( ) => {
512616 const det = new FlashDetector ( { tagName : 'VIDEO' } , { } ) ;
0 commit comments