@@ -441,4 +441,307 @@ describe('detectFailures', () => {
441441 expect ( failures . some ( ( f ) => f . code === 'CONNECTOR_FAULT' ) ) . toBe ( true ) ;
442442 } ) ;
443443 } ) ;
444+
445+ // -------------------------------------------------------------------------
446+ // v0.2 detection rules
447+ // -------------------------------------------------------------------------
448+
449+ describe ( 'TIMEOUT_NO_HEARTBEAT' , ( ) => {
450+ it ( 'detects missing heartbeat when trace spans beyond threshold' , ( ) => {
451+ const events = [
452+ makeEvent ( 'e1' , 'm1' , 'Call' , 'BootNotification' , { } , 0 ) ,
453+ makeEvent (
454+ 'e2' ,
455+ 'm1' ,
456+ 'CallResult' ,
457+ null ,
458+ { interval : 300 , status : 'Accepted' } ,
459+ 500 ,
460+ 'CSMS_TO_CS' ,
461+ ) ,
462+ // No heartbeat, but events span beyond 600s (2x300)
463+ makeEvent ( 'e3' , 'm2' , 'Call' , 'StatusNotification' , { status : 'Available' } , 700_000 ) ,
464+ ] ;
465+ const sessions = buildSessionTimeline ( events ) ;
466+ const failures = detectFailures ( events , sessions ) ;
467+ expect ( failures . some ( ( f ) => f . code === 'TIMEOUT_NO_HEARTBEAT' ) ) . toBe ( true ) ;
468+ } ) ;
469+
470+ it ( 'does not flag when heartbeat is present' , ( ) => {
471+ const events = [
472+ makeEvent ( 'e1' , 'm1' , 'Call' , 'BootNotification' , { } , 0 ) ,
473+ makeEvent (
474+ 'e2' ,
475+ 'm1' ,
476+ 'CallResult' ,
477+ null ,
478+ { interval : 300 , status : 'Accepted' } ,
479+ 500 ,
480+ 'CSMS_TO_CS' ,
481+ ) ,
482+ makeEvent ( 'e3' , 'm2' , 'Call' , 'Heartbeat' , { } , 100_000 ) ,
483+ makeEvent ( 'e4' , 'm2' , 'CallResult' , null , { } , 100_500 , 'CSMS_TO_CS' ) ,
484+ makeEvent ( 'e5' , 'm3' , 'Call' , 'StatusNotification' , { status : 'Available' } , 700_000 ) ,
485+ ] ;
486+ const sessions = buildSessionTimeline ( events ) ;
487+ const failures = detectFailures ( events , sessions ) ;
488+ expect ( failures . some ( ( f ) => f . code === 'TIMEOUT_NO_HEARTBEAT' ) ) . toBe ( false ) ;
489+ } ) ;
490+
491+ it ( 'does not flag when trace ends before first heartbeat is due' , ( ) => {
492+ const events = [
493+ makeEvent ( 'e1' , 'm1' , 'Call' , 'BootNotification' , { } , 0 ) ,
494+ makeEvent (
495+ 'e2' ,
496+ 'm1' ,
497+ 'CallResult' ,
498+ null ,
499+ { interval : 300 , status : 'Accepted' } ,
500+ 500 ,
501+ 'CSMS_TO_CS' ,
502+ ) ,
503+ // Trace ends at 100s — before 600s threshold
504+ makeEvent ( 'e3' , 'm2' , 'Call' , 'StatusNotification' , { status : 'Available' } , 100_000 ) ,
505+ ] ;
506+ const sessions = buildSessionTimeline ( events ) ;
507+ const failures = detectFailures ( events , sessions ) ;
508+ expect ( failures . some ( ( f ) => f . code === 'TIMEOUT_NO_HEARTBEAT' ) ) . toBe ( false ) ;
509+ } ) ;
510+
511+ it ( 'does not flag when no BootNotification' , ( ) => {
512+ const events = [ makeEvent ( 'e1' , 'm1' , 'Call' , 'Heartbeat' , { } , 0 ) ] ;
513+ const sessions = buildSessionTimeline ( events ) ;
514+ const failures = detectFailures ( events , sessions ) ;
515+ expect ( failures . some ( ( f ) => f . code === 'TIMEOUT_NO_HEARTBEAT' ) ) . toBe ( false ) ;
516+ } ) ;
517+ } ) ;
518+
519+ describe ( 'METER_VALUE_GAP' , ( ) => {
520+ it ( 'detects missing MeterValues in completed transaction' , ( ) => {
521+ const events = [
522+ makeEvent ( 'e1' , 'm1' , 'Call' , 'StartTransaction' , { connectorId : 1 , transactionId : 1 } , 0 ) ,
523+ makeEvent ( 'e2' , 'm1' , 'CallResult' , null , { transactionId : 1 } , 500 , 'CSMS_TO_CS' ) ,
524+ makeEvent (
525+ 'e3' ,
526+ 'm2' ,
527+ 'Call' ,
528+ 'StopTransaction' ,
529+ { transactionId : 1 , reason : 'EVDisconnected' } ,
530+ 10_000 ,
531+ ) ,
532+ makeEvent ( 'e4' , 'm2' , 'CallResult' , null , { } , 10_500 , 'CSMS_TO_CS' ) ,
533+ ] ;
534+ const sessions = buildSessionTimeline ( events ) ;
535+ const failures = detectFailures ( events , sessions ) ;
536+ expect ( failures . some ( ( f ) => f . code === 'METER_VALUE_GAP' ) ) . toBe ( true ) ;
537+ } ) ;
538+
539+ it ( 'does not flag when MeterValues are present' , ( ) => {
540+ const events = [
541+ makeEvent ( 'e1' , 'm1' , 'Call' , 'StartTransaction' , { connectorId : 1 , transactionId : 1 } , 0 ) ,
542+ makeEvent ( 'e2' , 'm1' , 'CallResult' , null , { transactionId : 1 } , 500 , 'CSMS_TO_CS' ) ,
543+ makeEvent ( 'e3' , 'm2' , 'Call' , 'MeterValues' , { connectorId : 1 , transactionId : 1 } , 5_000 ) ,
544+ makeEvent ( 'e4' , 'm2' , 'CallResult' , null , { } , 5_500 , 'CSMS_TO_CS' ) ,
545+ makeEvent (
546+ 'e5' ,
547+ 'm3' ,
548+ 'Call' ,
549+ 'StopTransaction' ,
550+ { transactionId : 1 , reason : 'EVDisconnected' } ,
551+ 10_000 ,
552+ ) ,
553+ makeEvent ( 'e6' , 'm3' , 'CallResult' , null , { } , 10_500 , 'CSMS_TO_CS' ) ,
554+ ] ;
555+ const sessions = buildSessionTimeline ( events ) ;
556+ const failures = detectFailures ( events , sessions ) ;
557+ expect ( failures . some ( ( f ) => f . code === 'METER_VALUE_GAP' ) ) . toBe ( false ) ;
558+ } ) ;
559+ } ) ;
560+
561+ describe ( 'INVALID_STOP_REASON' , ( ) => {
562+ it ( 'detects invalid stop reason' , ( ) => {
563+ const events = [
564+ makeEvent (
565+ 'e1' ,
566+ 'm1' ,
567+ 'Call' ,
568+ 'StopTransaction' ,
569+ { transactionId : 1 , reason : 'BadReason' } ,
570+ 0 ,
571+ ) ,
572+ ] ;
573+ const sessions = buildSessionTimeline ( events ) ;
574+ const failures = detectFailures ( events , sessions ) ;
575+ expect ( failures . some ( ( f ) => f . code === 'INVALID_STOP_REASON' ) ) . toBe ( true ) ;
576+ } ) ;
577+
578+ it ( 'does not flag valid stop reason' , ( ) => {
579+ const events = [
580+ makeEvent (
581+ 'e1' ,
582+ 'm1' ,
583+ 'Call' ,
584+ 'StopTransaction' ,
585+ { transactionId : 1 , reason : 'EVDisconnected' } ,
586+ 0 ,
587+ ) ,
588+ ] ;
589+ const sessions = buildSessionTimeline ( events ) ;
590+ const failures = detectFailures ( events , sessions ) ;
591+ expect ( failures . some ( ( f ) => f . code === 'INVALID_STOP_REASON' ) ) . toBe ( false ) ;
592+ } ) ;
593+ } ) ;
594+
595+ describe ( 'UNEXPECTED_START' , ( ) => {
596+ it ( 'detects StartTransaction without BootNotification or Authorize' , ( ) => {
597+ const events = [
598+ makeEvent ( 'e1' , 'm1' , 'Call' , 'StartTransaction' , { connectorId : 1 } , 0 ) ,
599+ makeEvent ( 'e2' , 'm1' , 'CallResult' , null , { transactionId : 1 } , 500 , 'CSMS_TO_CS' ) ,
600+ ] ;
601+ const sessions = buildSessionTimeline ( events ) ;
602+ const failures = detectFailures ( events , sessions ) ;
603+ expect ( failures . some ( ( f ) => f . code === 'UNEXPECTED_START' ) ) . toBe ( true ) ;
604+ } ) ;
605+
606+ it ( 'does not flag when BootNotification precedes StartTransaction' , ( ) => {
607+ const events = [
608+ makeEvent ( 'e1' , 'm1' , 'Call' , 'BootNotification' , { } , 0 ) ,
609+ makeEvent ( 'e2' , 'm1' , 'CallResult' , null , { status : 'Accepted' } , 500 , 'CSMS_TO_CS' ) ,
610+ makeEvent ( 'e3' , 'm2' , 'Call' , 'StartTransaction' , { connectorId : 1 } , 1000 ) ,
611+ makeEvent ( 'e4' , 'm2' , 'CallResult' , null , { transactionId : 1 } , 1500 , 'CSMS_TO_CS' ) ,
612+ ] ;
613+ const sessions = buildSessionTimeline ( events ) ;
614+ const failures = detectFailures ( events , sessions ) ;
615+ expect ( failures . some ( ( f ) => f . code === 'UNEXPECTED_START' ) ) . toBe ( false ) ;
616+ } ) ;
617+ } ) ;
618+
619+ describe ( 'STATUS_TRANSITION_VIOLATION' , ( ) => {
620+ it ( 'detects illegal transition from Available to Finishing' , ( ) => {
621+ const events = [
622+ makeEvent (
623+ 'e1' ,
624+ 'm1' ,
625+ 'Call' ,
626+ 'StatusNotification' ,
627+ { connectorId : 1 , status : 'Available' } ,
628+ 0 ,
629+ ) ,
630+ makeEvent (
631+ 'e2' ,
632+ 'm2' ,
633+ 'Call' ,
634+ 'StatusNotification' ,
635+ { connectorId : 1 , status : 'Finishing' } ,
636+ 1000 ,
637+ ) ,
638+ ] ;
639+ const sessions = buildSessionTimeline ( events ) ;
640+ const failures = detectFailures ( events , sessions ) ;
641+ expect ( failures . some ( ( f ) => f . code === 'STATUS_TRANSITION_VIOLATION' ) ) . toBe ( true ) ;
642+ } ) ;
643+
644+ it ( 'does not flag valid transition from Available to Preparing' , ( ) => {
645+ const events = [
646+ makeEvent (
647+ 'e1' ,
648+ 'm1' ,
649+ 'Call' ,
650+ 'StatusNotification' ,
651+ { connectorId : 1 , status : 'Available' } ,
652+ 0 ,
653+ ) ,
654+ makeEvent (
655+ 'e2' ,
656+ 'm2' ,
657+ 'Call' ,
658+ 'StatusNotification' ,
659+ { connectorId : 1 , status : 'Preparing' } ,
660+ 1000 ,
661+ ) ,
662+ ] ;
663+ const sessions = buildSessionTimeline ( events ) ;
664+ const failures = detectFailures ( events , sessions ) ;
665+ expect ( failures . some ( ( f ) => f . code === 'STATUS_TRANSITION_VIOLATION' ) ) . toBe ( false ) ;
666+ } ) ;
667+ } ) ;
668+
669+ describe ( 'DIAGNOSTICS_FAILURE' , ( ) => {
670+ it ( 'detects UploadFailed diagnostics status' , ( ) => {
671+ const events = [
672+ makeEvent (
673+ 'e1' ,
674+ 'm1' ,
675+ 'Call' ,
676+ 'DiagnosticsStatusNotification' ,
677+ { status : 'UploadFailed' } ,
678+ 0 ,
679+ ) ,
680+ ] ;
681+ const sessions = buildSessionTimeline ( events ) ;
682+ const failures = detectFailures ( events , sessions ) ;
683+ expect ( failures . some ( ( f ) => f . code === 'DIAGNOSTICS_FAILURE' ) ) . toBe ( true ) ;
684+ } ) ;
685+
686+ it ( 'detects DiagnosisFailed diagnostics status' , ( ) => {
687+ const events = [
688+ makeEvent (
689+ 'e1' ,
690+ 'm1' ,
691+ 'Call' ,
692+ 'DiagnosticsStatusNotification' ,
693+ { status : 'DiagnosisFailed' } ,
694+ 0 ,
695+ ) ,
696+ ] ;
697+ const sessions = buildSessionTimeline ( events ) ;
698+ const failures = detectFailures ( events , sessions ) ;
699+ expect ( failures . some ( ( f ) => f . code === 'DIAGNOSTICS_FAILURE' ) ) . toBe ( true ) ;
700+ } ) ;
701+
702+ it ( 'does not flag Uploaded diagnostics status' , ( ) => {
703+ const events = [
704+ makeEvent ( 'e1' , 'm1' , 'Call' , 'DiagnosticsStatusNotification' , { status : 'Uploaded' } , 0 ) ,
705+ ] ;
706+ const sessions = buildSessionTimeline ( events ) ;
707+ const failures = detectFailures ( events , sessions ) ;
708+ expect ( failures . some ( ( f ) => f . code === 'DIAGNOSTICS_FAILURE' ) ) . toBe ( false ) ;
709+ } ) ;
710+ } ) ;
711+
712+ describe ( 'FIRMWARE_UPDATE_FAILURE' , ( ) => {
713+ it ( 'detects DownloadFailed firmware status' , ( ) => {
714+ const events = [
715+ makeEvent (
716+ 'e1' ,
717+ 'm1' ,
718+ 'Call' ,
719+ 'FirmwareStatusNotification' ,
720+ { status : 'DownloadFailed' } ,
721+ 0 ,
722+ ) ,
723+ ] ;
724+ const sessions = buildSessionTimeline ( events ) ;
725+ const failures = detectFailures ( events , sessions ) ;
726+ expect ( failures . some ( ( f ) => f . code === 'FIRMWARE_UPDATE_FAILURE' ) ) . toBe ( true ) ;
727+ } ) ;
728+
729+ it ( 'detects InstallFailed firmware status' , ( ) => {
730+ const events = [
731+ makeEvent ( 'e1' , 'm1' , 'Call' , 'FirmwareStatusNotification' , { status : 'InstallFailed' } , 0 ) ,
732+ ] ;
733+ const sessions = buildSessionTimeline ( events ) ;
734+ const failures = detectFailures ( events , sessions ) ;
735+ expect ( failures . some ( ( f ) => f . code === 'FIRMWARE_UPDATE_FAILURE' ) ) . toBe ( true ) ;
736+ } ) ;
737+
738+ it ( 'does not flag Downloaded firmware status' , ( ) => {
739+ const events = [
740+ makeEvent ( 'e1' , 'm1' , 'Call' , 'FirmwareStatusNotification' , { status : 'Downloaded' } , 0 ) ,
741+ ] ;
742+ const sessions = buildSessionTimeline ( events ) ;
743+ const failures = detectFailures ( events , sessions ) ;
744+ expect ( failures . some ( ( f ) => f . code === 'FIRMWARE_UPDATE_FAILURE' ) ) . toBe ( false ) ;
745+ } ) ;
746+ } ) ;
444747} ) ;
0 commit comments