@@ -479,6 +479,372 @@ describe('Timeline tests', () => {
479479 expect ( timeline . seqtimeline [ 3 ] . meta . prev ) . toBe ( 'one-b' ) // three prev is two
480480 expect ( timeline . seqtimeline [ 4 ] . meta . prev ) . toBe ( 'three' ) // four prev is three
481481 } )
482+ describe ( 'Randomized nodes' , ( ) => {
483+ beforeEach ( ( ) => {
484+ // Mock store methods for randomized routes
485+ api . store . getRandomizedRouteByName = vi . fn ( ) . mockReturnValue ( null )
486+ api . store . setRandomizedRoute = vi . fn ( )
487+ // Mock sampleWithReplacement to return first option deterministically
488+ api . sampleWithReplacement = vi . fn ( ) . mockImplementation ( ( options ) => [ options [ 0 ] ] )
489+ } )
490+
491+ it ( 'should push a randomized node and connect routes in correct order' , ( ) => {
492+ const timeline = new Timeline ( api )
493+ addWelcomeRoute ( timeline )
494+
495+ // Register the views first (not in sequence)
496+ timeline . registerView ( {
497+ name : 'task1' ,
498+ component : MockComponent ,
499+ } )
500+ timeline . registerView ( {
501+ name : 'task2' ,
502+ component : MockComponent ,
503+ } )
504+
505+ const options = [
506+ [ 'task1' , 'task2' ] ,
507+ [ 'task2' , 'task1' ] ,
508+ ]
509+
510+ // Push the randomized node - will select first option ['task1', 'task2']
511+ timeline . pushRandomizedNode ( {
512+ name : 'randomOrder' ,
513+ options,
514+ } )
515+
516+ timeline . build ( )
517+
518+ // Verify sampleWithReplacement was called with correct arguments
519+ expect ( api . sampleWithReplacement ) . toHaveBeenCalledWith ( options , 1 , undefined )
520+
521+ // Verify the selection was persisted
522+ expect ( api . store . setRandomizedRoute ) . toHaveBeenCalledWith ( 'randomOrder' , [ 'task1' , 'task2' ] )
523+
524+ // Verify the sequential timeline has correct order
525+ expect ( timeline . seqtimeline . length ) . toBe ( 3 ) // welcome + 2 tasks
526+ expect ( timeline . seqtimeline [ 0 ] . name ) . toBe ( 'welcome_anonymous' )
527+ expect ( timeline . seqtimeline [ 1 ] . name ) . toBe ( 'task1' )
528+ expect ( timeline . seqtimeline [ 2 ] . name ) . toBe ( 'task2' )
529+
530+ // Verify next/prev links form correct traversal
531+ expect ( timeline . seqtimeline [ 0 ] . meta . next ) . toBe ( 'task1' )
532+ expect ( timeline . seqtimeline [ 1 ] . meta . next ) . toBe ( 'task2' )
533+ expect ( timeline . seqtimeline [ 2 ] . meta . next ) . toBe ( null )
534+
535+ expect ( timeline . seqtimeline [ 0 ] . meta . prev ) . toBe ( null )
536+ expect ( timeline . seqtimeline [ 1 ] . meta . prev ) . toBe ( 'welcome_anonymous' )
537+ expect ( timeline . seqtimeline [ 2 ] . meta . prev ) . toBe ( 'task1' )
538+ } )
539+
540+ it ( 'should select second option when randomizer returns it' , ( ) => {
541+ // Change mock to return second option
542+ api . sampleWithReplacement = vi . fn ( ) . mockImplementation ( ( options ) => [ options [ 1 ] ] )
543+
544+ const timeline = new Timeline ( api )
545+ addWelcomeRoute ( timeline )
546+
547+ timeline . registerView ( { name : 'task1' , component : MockComponent } )
548+ timeline . registerView ( { name : 'task2' , component : MockComponent } )
549+
550+ const options = [
551+ [ 'task1' , 'task2' ] ,
552+ [ 'task2' , 'task1' ] ,
553+ ]
554+
555+ timeline . pushRandomizedNode ( {
556+ name : 'randomOrder' ,
557+ options,
558+ } )
559+
560+ timeline . build ( )
561+
562+ // Verify sampleWithReplacement was called with correct arguments
563+ expect ( api . sampleWithReplacement ) . toHaveBeenCalledWith ( options , 1 , undefined )
564+
565+ // Verify the second option was persisted
566+ expect ( api . store . setRandomizedRoute ) . toHaveBeenCalledWith ( 'randomOrder' , [ 'task2' , 'task1' ] )
567+
568+ // Verify reversed order
569+ expect ( timeline . seqtimeline [ 1 ] . name ) . toBe ( 'task2' )
570+ expect ( timeline . seqtimeline [ 2 ] . name ) . toBe ( 'task1' )
571+
572+ // Verify traversal links
573+ expect ( timeline . seqtimeline [ 0 ] . meta . next ) . toBe ( 'task2' )
574+ expect ( timeline . seqtimeline [ 1 ] . meta . next ) . toBe ( 'task1' )
575+ expect ( timeline . seqtimeline [ 2 ] . meta . next ) . toBe ( null )
576+ } )
577+
578+ it ( 'should use stored route if already assigned' , ( ) => {
579+ // Mock that route was already assigned
580+ api . store . getRandomizedRouteByName = vi . fn ( ) . mockReturnValue ( [ 'task2' , 'task1' ] )
581+
582+ const timeline = new Timeline ( api )
583+ addWelcomeRoute ( timeline )
584+
585+ timeline . registerView ( { name : 'task1' , component : MockComponent } )
586+ timeline . registerView ( { name : 'task2' , component : MockComponent } )
587+
588+ timeline . pushRandomizedNode ( {
589+ name : 'randomOrder' ,
590+ options : [
591+ [ 'task1' , 'task2' ] ,
592+ [ 'task2' , 'task1' ] ,
593+ ] ,
594+ } )
595+
596+ timeline . build ( )
597+
598+ // Verify stored route was looked up
599+ expect ( api . store . getRandomizedRouteByName ) . toHaveBeenCalledWith ( 'randomOrder' )
600+
601+ // Should NOT call sampleWithReplacement when route is already stored
602+ expect ( api . sampleWithReplacement ) . not . toHaveBeenCalled ( )
603+
604+ // Should use stored order
605+ expect ( timeline . seqtimeline [ 1 ] . name ) . toBe ( 'task2' )
606+ expect ( timeline . seqtimeline [ 2 ] . name ) . toBe ( 'task1' )
607+ } )
608+
609+ it ( 'should throw error if randomized option not found' , ( ) => {
610+ const timeline = new Timeline ( api )
611+ addWelcomeRoute ( timeline )
612+
613+ expect ( ( ) => {
614+ timeline . pushRandomizedNode ( {
615+ name : 'randomOrder' ,
616+ options : [ [ 'nonexistent_task' ] ] ,
617+ } )
618+ } ) . toThrow ( 'RandomizedNodeOptionNotFoundError' )
619+ } )
620+
621+ it ( 'should throw error if options and weights length mismatch' , ( ) => {
622+ const timeline = new Timeline ( api )
623+ addWelcomeRoute ( timeline )
624+
625+ timeline . registerView ( { name : 'task1' , component : MockComponent } )
626+
627+ expect ( ( ) => {
628+ timeline . pushRandomizedNode ( {
629+ name : 'randomOrder' ,
630+ options : [ [ 'task1' ] ] ,
631+ weights : [ 1 , 2 ] , // mismatched length
632+ } )
633+ } ) . toThrow ( 'OptionsWeightsLengthMismatchError' )
634+ } )
635+ } )
636+
637+ describe ( 'Conditional nodes' , ( ) => {
638+ beforeEach ( ( ) => {
639+ // Mock getConditionByName to return 'AB' condition
640+ api . getConditionByName = vi . fn ( ) . mockReturnValue ( 'AB' )
641+ api . randomAssignCondition = vi . fn ( ) . mockReturnValue ( 'AB' )
642+ } )
643+
644+ it ( 'should push conditional node and connect routes based on condition' , ( ) => {
645+ const timeline = new Timeline ( api )
646+ addWelcomeRoute ( timeline )
647+
648+ timeline . registerView ( { name : 'taskA' , component : MockComponent } )
649+ timeline . registerView ( { name : 'taskB' , component : MockComponent } )
650+
651+ timeline . pushConditionalNode ( {
652+ name : 'conditionalOrder' ,
653+ taskOrder : {
654+ AB : [ 'taskA' , 'taskB' ] ,
655+ BA : [ 'taskB' , 'taskA' ] ,
656+ } ,
657+ } )
658+
659+ timeline . build ( )
660+
661+ // Verify condition was looked up by the correct name (the condition property key)
662+ expect ( api . getConditionByName ) . toHaveBeenCalledWith ( 'taskOrder' )
663+
664+ // Condition is 'AB', so order should be taskA -> taskB
665+ expect ( timeline . seqtimeline . length ) . toBe ( 3 )
666+ expect ( timeline . seqtimeline [ 0 ] . name ) . toBe ( 'welcome_anonymous' )
667+ expect ( timeline . seqtimeline [ 1 ] . name ) . toBe ( 'taskA' )
668+ expect ( timeline . seqtimeline [ 2 ] . name ) . toBe ( 'taskB' )
669+
670+ // Verify traversal links
671+ expect ( timeline . seqtimeline [ 0 ] . meta . next ) . toBe ( 'taskA' )
672+ expect ( timeline . seqtimeline [ 1 ] . meta . next ) . toBe ( 'taskB' )
673+ expect ( timeline . seqtimeline [ 2 ] . meta . next ) . toBe ( null )
674+
675+ expect ( timeline . seqtimeline [ 0 ] . meta . prev ) . toBe ( null )
676+ expect ( timeline . seqtimeline [ 1 ] . meta . prev ) . toBe ( 'welcome_anonymous' )
677+ expect ( timeline . seqtimeline [ 2 ] . meta . prev ) . toBe ( 'taskA' )
678+ } )
679+
680+ it ( 'should use different order for different condition' , ( ) => {
681+ // Change condition to 'BA'
682+ api . getConditionByName = vi . fn ( ) . mockReturnValue ( 'BA' )
683+
684+ const timeline = new Timeline ( api )
685+ addWelcomeRoute ( timeline )
686+
687+ timeline . registerView ( { name : 'taskA' , component : MockComponent } )
688+ timeline . registerView ( { name : 'taskB' , component : MockComponent } )
689+
690+ timeline . pushConditionalNode ( {
691+ name : 'conditionalOrder' ,
692+ taskOrder : {
693+ AB : [ 'taskA' , 'taskB' ] ,
694+ BA : [ 'taskB' , 'taskA' ] ,
695+ } ,
696+ } )
697+
698+ timeline . build ( )
699+
700+ // Verify condition was looked up
701+ expect ( api . getConditionByName ) . toHaveBeenCalledWith ( 'taskOrder' )
702+
703+ // Condition is 'BA', so order should be taskB -> taskA
704+ expect ( timeline . seqtimeline [ 1 ] . name ) . toBe ( 'taskB' )
705+ expect ( timeline . seqtimeline [ 2 ] . name ) . toBe ( 'taskA' )
706+
707+ // Verify traversal links
708+ expect ( timeline . seqtimeline [ 0 ] . meta . next ) . toBe ( 'taskB' )
709+ expect ( timeline . seqtimeline [ 1 ] . meta . next ) . toBe ( 'taskA' )
710+ } )
711+
712+ it ( 'should handle conditional node with explicit path property' , ( ) => {
713+ // This test verifies the fix for the bug where 'path' was being
714+ // treated as a condition name instead of being ignored
715+ const timeline = new Timeline ( api )
716+ addWelcomeRoute ( timeline )
717+
718+ timeline . registerView ( { name : 'taskA' , component : MockComponent } )
719+ timeline . registerView ( { name : 'taskB' , component : MockComponent } )
720+
721+ // Push conditional node with explicit path - this should NOT throw
722+ // Before the fix, this would throw TooManyConditionNamesError
723+ // because 'path' was being counted as a second condition name
724+ timeline . pushConditionalNode ( {
725+ name : 'conditionalOrder' ,
726+ path : '/conditional' ,
727+ taskOrder : {
728+ AB : [ 'taskA' , 'taskB' ] ,
729+ BA : [ 'taskB' , 'taskA' ] ,
730+ } ,
731+ } )
732+
733+ timeline . build ( )
734+
735+ // CRITICAL: Verify the condition lookup used 'taskOrder', NOT 'path'
736+ // This is the key assertion that verifies the bug fix
737+ expect ( api . getConditionByName ) . toHaveBeenCalledWith ( 'taskOrder' )
738+ expect ( api . getConditionByName ) . not . toHaveBeenCalledWith ( 'path' )
739+
740+ // Should work correctly with condition 'AB'
741+ expect ( timeline . seqtimeline . length ) . toBe ( 3 )
742+ expect ( timeline . seqtimeline [ 1 ] . name ) . toBe ( 'taskA' )
743+ expect ( timeline . seqtimeline [ 2 ] . name ) . toBe ( 'taskB' )
744+
745+ // Verify traversal is correct
746+ expect ( timeline . seqtimeline [ 0 ] . meta . next ) . toBe ( 'taskA' )
747+ expect ( timeline . seqtimeline [ 1 ] . meta . next ) . toBe ( 'taskB' )
748+ } )
749+
750+ it ( 'should throw error with multiple condition names' , ( ) => {
751+ const timeline = new Timeline ( api )
752+ addWelcomeRoute ( timeline )
753+
754+ timeline . registerView ( { name : 'taskA' , component : MockComponent } )
755+
756+ // Two condition names should throw - using realistic property names
757+ // that a developer might accidentally add (e.g., taskOrder AND variation)
758+ expect ( ( ) => {
759+ timeline . pushConditionalNode ( {
760+ name : 'conditionalOrder' ,
761+ taskOrder : { AB : [ 'taskA' ] , BA : [ 'taskA' ] } ,
762+ variation : { X : [ 'taskA' ] , Y : [ 'taskA' ] } ,
763+ } )
764+ } ) . toThrow ( 'TooManyConditionNamesError' )
765+ } )
766+
767+ it ( 'should randomly assign condition if not already set' , ( ) => {
768+ api . getConditionByName = vi . fn ( ) . mockReturnValue ( null )
769+ api . randomAssignCondition = vi . fn ( ) . mockReturnValue ( 'AB' )
770+
771+ const timeline = new Timeline ( api )
772+ addWelcomeRoute ( timeline )
773+
774+ timeline . registerView ( { name : 'taskA' , component : MockComponent } )
775+ timeline . registerView ( { name : 'taskB' , component : MockComponent } )
776+
777+ timeline . pushConditionalNode ( {
778+ name : 'conditionalOrder' ,
779+ taskOrder : {
780+ AB : [ 'taskA' , 'taskB' ] ,
781+ BA : [ 'taskB' , 'taskA' ] ,
782+ } ,
783+ } )
784+
785+ timeline . build ( )
786+
787+ // Verify getConditionByName was checked first
788+ expect ( api . getConditionByName ) . toHaveBeenCalledWith ( 'taskOrder' )
789+
790+ // Should have called randomAssignCondition with the possible condition values
791+ expect ( api . randomAssignCondition ) . toHaveBeenCalledWith ( {
792+ conditionname : [ 'AB' , 'BA' ] ,
793+ } )
794+
795+ // And still produce correct traversal based on randomly assigned 'AB'
796+ expect ( timeline . seqtimeline [ 1 ] . name ) . toBe ( 'taskA' )
797+ expect ( timeline . seqtimeline [ 2 ] . name ) . toBe ( 'taskB' )
798+ } )
799+
800+ it ( 'should connect conditional node between other sequential views' , ( ) => {
801+ const timeline = new Timeline ( api )
802+ addWelcomeRoute ( timeline )
803+
804+ // Push instructions before conditional node
805+ timeline . pushSeqView ( {
806+ name : 'instructions' ,
807+ component : MockComponent ,
808+ } )
809+
810+ timeline . registerView ( { name : 'taskA' , component : MockComponent } )
811+ timeline . registerView ( { name : 'taskB' , component : MockComponent } )
812+
813+ timeline . pushConditionalNode ( {
814+ name : 'conditionalOrder' ,
815+ taskOrder : {
816+ AB : [ 'taskA' , 'taskB' ] ,
817+ BA : [ 'taskB' , 'taskA' ] ,
818+ } ,
819+ } )
820+
821+ // Push debrief after conditional node
822+ timeline . pushSeqView ( {
823+ name : 'debrief' ,
824+ component : MockComponent ,
825+ } )
826+
827+ timeline . build ( )
828+
829+ // Verify full sequence: welcome -> instructions -> taskA -> taskB -> debrief
830+ expect ( timeline . seqtimeline . length ) . toBe ( 5 )
831+ expect ( timeline . seqtimeline . map ( ( r ) => r . name ) ) . toEqual ( [
832+ 'welcome_anonymous' ,
833+ 'instructions' ,
834+ 'taskA' ,
835+ 'taskB' ,
836+ 'debrief' ,
837+ ] )
838+
839+ // Verify complete traversal chain
840+ expect ( timeline . seqtimeline [ 0 ] . meta . next ) . toBe ( 'instructions' )
841+ expect ( timeline . seqtimeline [ 1 ] . meta . next ) . toBe ( 'taskA' )
842+ expect ( timeline . seqtimeline [ 2 ] . meta . next ) . toBe ( 'taskB' )
843+ expect ( timeline . seqtimeline [ 3 ] . meta . next ) . toBe ( 'debrief' )
844+ expect ( timeline . seqtimeline [ 4 ] . meta . next ) . toBe ( null )
845+ } )
846+ } )
847+
482848 /*
483849 it('should compute the progress correctly', () => {
484850 const MockComponentOne = { template: '<div>Mock Component One</div>' }
0 commit comments