Skip to content

Commit 57a027b

Browse files
authored
fix: pushConditionalNode error and add tests for random and conditional timeline nodes (closes #257)
* fix: pushConditionalNode error claude code suggested that path was being added automatically and causing this error so it should be removed. problem: this part of the time line isn't tested? * test: add tests covering random and conditional nodes previously these were not covered
1 parent 16134ac commit 57a027b

2 files changed

Lines changed: 368 additions & 2 deletions

File tree

src/core/timeline/Timeline.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,8 @@ class Timeline {
359359
}
360360
}
361361

362-
// get condition name—anything that's not name
363-
const conditionname = Object.keys(newroute).filter((key) => key !== 'name')
362+
// get condition name—anything that's not name or path
363+
const conditionname = Object.keys(newroute).filter((key) => key !== 'name' && key !== 'path')
364364
if (conditionname.length > 1) {
365365
this.api.log.error('Can only branch routes based on one condition at a time')
366366
throw new Error('TooManyConditionNamesError')

tests/vitest/core/timeline/Timeline.test.js

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)