diff --git a/package.json b/package.json
index 9cb61c894f..17d4a5c266 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"test:ci": "TZ='America/New_York' CI=true jest --ci --useStderr --coverage --coverageReporters text-summary cobertura",
"test:ci:each": "lerna run test:ci",
"test:dev": "TZ='America/New_York' jest --verbose --watchAll --coverage --coverageReporters lcov",
+ "test:view-lcov": "open ./coverage/lcov-report/index.html",
"postinstall": "husky install"
},
"devDependencies": {
diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/components/nav.test.js b/packages/app/obojobo-document-engine/__tests__/Viewer/components/nav.test.js
index f072903c55..a492627dd8 100644
--- a/packages/app/obojobo-document-engine/__tests__/Viewer/components/nav.test.js
+++ b/packages/app/obojobo-document-engine/__tests__/Viewer/components/nav.test.js
@@ -126,6 +126,7 @@ describe('Nav', () => {
beforeEach(() => {
jest.clearAllMocks()
+ mockDispatcherTrigger.mockReset()
})
test('renders opened', () => {
@@ -195,6 +196,148 @@ describe('Nav', () => {
expect(tree).toMatchSnapshot()
})
+ test('does not try to substitute labels if no brackets are found', () => {
+ // this shouldn't run in this test, but if it does the label will be changed
+ mockDispatcherTrigger.mockImplementationOnce((trigger, event) => {
+ event.text = 'not-label5'
+ return event
+ })
+ NavUtil.getOrderedList.mockReturnValueOnce([
+ { id: 4, type: 'heading', label: 'label4' },
+ {
+ id: 5,
+ type: 'link',
+ label: 'label5',
+ flags: { visited: false, complete: false, correct: false },
+ // exists to be passed along to variable utilities, doesn't matter for testing
+ sourceModel: {}
+ }
+ ])
+ const props = {
+ navState: {
+ open: false,
+ locked: true,
+ navTargetId: 56 // select this item
+ }
+ }
+ const component = renderer.create()
+
+ const liElements = component.root.findAllByType('li')
+ expect(liElements[1].children[0].children[0]).toBe('label5')
+ expect(mockDispatcherTrigger).not.toHaveBeenCalled()
+ })
+
+ test('does not try to substitute labels if the variable text is not correctly formatted', () => {
+ // this shouldn't run in this test, but if it does the label will be changed
+ mockDispatcherTrigger.mockImplementationOnce((trigger, event) => {
+ event.text = 'not-label5'
+ return event
+ })
+ NavUtil.getOrderedList.mockReturnValueOnce([
+ { id: 4, type: 'heading', label: 'label4' },
+ {
+ id: 5,
+ type: 'link',
+ label: '{{label5',
+ flags: { visited: false, complete: false, correct: false },
+ // exists to be passed along to variable utilities, doesn't matter for testing
+ sourceModel: {}
+ }
+ ])
+ const props = {
+ navState: {
+ open: false,
+ locked: true,
+ navTargetId: 56 // select this item
+ }
+ }
+ const component = renderer.create()
+
+ const liElements = component.root.findAllByType('li')
+ expect(liElements[1].children[0].children[0]).toBe('{{label5')
+ expect(mockDispatcherTrigger).not.toHaveBeenCalled()
+ })
+
+ test('renders substituted variables in labels - substitute exists', () => {
+ // this should run and as a result modify the label text
+ mockDispatcherTrigger.mockImplementationOnce((trigger, event) => {
+ event.text = 'not-label5'
+ return event
+ })
+ NavUtil.getOrderedList.mockReturnValueOnce([
+ { id: 4, type: 'heading', label: 'label4' },
+ {
+ id: 5,
+ type: 'link',
+ label: '{{$var}}',
+ flags: { visited: false, complete: false, correct: false },
+ // exists to be passed along to variable utilities, doesn't matter for testing
+ sourceModel: {}
+ }
+ ])
+ const props = {
+ navState: {
+ open: false,
+ locked: true,
+ navTargetId: 56 // select this item
+ }
+ }
+ const component = renderer.create()
+
+ const liElements = component.root.findAllByType('li')
+ expect(liElements[1].children[0].children[0]).toBe('not-label5')
+ expect(mockDispatcherTrigger).toHaveBeenCalledTimes(1)
+ // so this second argument is weird - it isn't the thing that was originally sent to Dispatch.trigger,
+ // which was { text: '', isNav: true }, but because the callback triggered changed a property of the
+ // object it was passed, then it was technically also changing the object it was passed, which means
+ // we have to consider the state that object would have been in after the callback ran
+ expect(mockDispatcherTrigger).toHaveBeenCalledWith(
+ 'getTextForVariable',
+ { text: 'not-label5', isNav: true },
+ '$var',
+ {}
+ )
+ })
+
+ test('renders substituted variables in labels - no substitute exists', () => {
+ // this should run but as a result not modify the label text
+ mockDispatcherTrigger.mockImplementationOnce((trigger, event) => {
+ event.text = null
+ return event
+ })
+ NavUtil.getOrderedList.mockReturnValueOnce([
+ { id: 4, type: 'heading', label: 'label4' },
+ {
+ id: 5,
+ type: 'link',
+ label: '{{$var}}',
+ flags: { visited: false, complete: false, correct: false },
+ // exists to be passed along to variable utilities, doesn't matter for testing
+ sourceModel: {}
+ }
+ ])
+ const props = {
+ navState: {
+ open: false,
+ locked: true,
+ navTargetId: 56 // select this item
+ }
+ }
+ const component = renderer.create()
+
+ const liElements = component.root.findAllByType('li')
+ // event text was unchanged so original label text should be used
+ expect(liElements[1].children[0].children[0]).toBe('{{$var}}')
+ expect(mockDispatcherTrigger).toHaveBeenCalledTimes(1)
+ // see previous test note re: the second arg
+ expect(mockDispatcherTrigger).toHaveBeenCalledWith(
+ 'getTextForVariable',
+ { text: null, isNav: true },
+ '$var',
+ {}
+ )
+ })
+
test('renders blank title', () => {
NavUtil.getOrderedList.mockReturnValueOnce([{ id: 4, type: 'heading', label: '' }])
const props = {
diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/components/viewer-app.test.js b/packages/app/obojobo-document-engine/__tests__/Viewer/components/viewer-app.test.js
index d61787c2d9..9ab4bfbd14 100644
--- a/packages/app/obojobo-document-engine/__tests__/Viewer/components/viewer-app.test.js
+++ b/packages/app/obojobo-document-engine/__tests__/Viewer/components/viewer-app.test.js
@@ -23,6 +23,8 @@ import { mount } from 'enzyme'
import testObject from 'obojobo-document-engine/test-object.json'
import mockConsole from 'jest-mock-console'
import injectKatexIfNeeded from 'obojobo-document-engine/src/scripts/common/util/inject-katex-if-needed'
+import VariableStore from 'obojobo-document-engine/src/scripts/viewer/stores/variable-store'
+import VariableUtil from 'obojobo-document-engine/src/scripts/viewer/util/variable-util'
jest.mock('obojobo-document-engine/src/scripts/viewer/util/viewer-api')
jest.mock('obojobo-document-engine/src/scripts/common/util/inject-katex-if-needed')
@@ -40,6 +42,8 @@ jest.mock('obojobo-document-engine/src/scripts/viewer/components/nav')
jest.mock('obojobo-document-engine/src/scripts/common/page/dom-util')
jest.mock('obojobo-document-engine/src/scripts/common/util/insert-dom-tag')
jest.mock('obojobo-document-engine/src/scripts/common/components/modal-container')
+jest.mock('obojobo-document-engine/src/scripts/viewer/stores/variable-store')
+jest.mock('obojobo-document-engine/src/scripts/viewer/util/variable-util')
describe('ViewerApp', () => {
let restoreConsole
@@ -1025,7 +1029,7 @@ describe('ViewerApp', () => {
})
})
- test('sendCloseEvent calls navigator.sendBeacon', done => {
+ test('sendClose`Event calls navigator.sendBeacon', done => {
global.navigator.sendBeacon = jest.fn()
expect.assertions(1)
@@ -1841,4 +1845,155 @@ describe('ViewerApp', () => {
spy1.mockRestore()
spy2.mockRestore()
})
+
+ test('component passes variables to VariableStore correctly', done => {
+ mocksForMount()
+
+ const mockVariables = {
+ 'nodeid1:variablename1': 'var1',
+ 'nodeid1:variablename2': 'var2'
+ }
+
+ // reset the mocked function to test variables
+ ViewerAPI.requestStart = jest.fn().mockResolvedValueOnce({
+ status: 'ok',
+ value: {
+ visitId: 123,
+ lti: {
+ lisOutcomeServiceUrl: 'http://lis-outcome-service-url.test/example.php'
+ },
+ isPreviewing: true,
+ extensions: {
+ ':ObojoboDraft.Sections.Assessment:attemptHistory': []
+ },
+ variables: mockVariables
+ }
+ })
+ const component = mount()
+
+ setTimeout(() => {
+ expect(VariableStore.init).toHaveBeenCalledWith(mockVariables)
+ component.update()
+ done()
+ })
+ })
+
+ test('component calls expected VariableUtil functions, standard variable name', done => {
+ mocksForMount()
+
+ NavUtil.getContext.mockReturnValueOnce('test-context')
+
+ const mockVariables = {
+ 'nodeid1:variablename1': 'var1',
+ 'nodeid1:variablename2': 'var2'
+ }
+
+ // reset the mocked function to test variables
+ ViewerAPI.requestStart = jest.fn().mockResolvedValueOnce({
+ status: 'ok',
+ value: {
+ visitId: 123,
+ lti: {
+ lisOutcomeServiceUrl: 'http://lis-outcome-service-url.test/example.php'
+ },
+ isPreviewing: true,
+ extensions: {
+ ':ObojoboDraft.Sections.Assessment:attemptHistory': []
+ },
+ variables: mockVariables
+ }
+ })
+
+ const mockVariableState = { mockVariableStateKey: 'mockVariableStateVal' }
+
+ VariableStore.getState.mockReturnValueOnce(mockVariableState)
+
+ const component = mount()
+
+ setTimeout(() => {
+ component.update()
+
+ VariableUtil.findValueWithModel.mockReturnValueOnce('mock-var-value')
+
+ // ordinarily this is an oboModel instance, but it doesn't matter for tests
+ const mockTextModel = { key: 'val' }
+ const mockEvent = { text: '' }
+
+ component.instance().getTextForVariable(mockEvent, '$variablename1', mockTextModel)
+ expect(VariableUtil.findValueWithModel).toHaveBeenCalledWith(
+ 'test-context',
+ mockVariableState,
+ mockTextModel,
+ 'variablename1'
+ )
+ expect(VariableUtil.getValue).not.toHaveBeenCalled()
+
+ expect(mockEvent.text).toEqual('mock-var-value')
+
+ done()
+ })
+ })
+
+ test('component calls expected VariableUtil functions, owner:variable name', done => {
+ mocksForMount()
+
+ NavUtil.getContext.mockReturnValueOnce('test-context')
+
+ const mockVariables = {
+ 'nodeid1:variablename1': 'var1',
+ 'nodeid1:variablename2': 'var2'
+ }
+
+ // reset the mocked function to test variables
+ ViewerAPI.requestStart = jest.fn().mockResolvedValueOnce({
+ status: 'ok',
+ value: {
+ visitId: 123,
+ lti: {
+ lisOutcomeServiceUrl: 'http://lis-outcome-service-url.test/example.php'
+ },
+ isPreviewing: true,
+ extensions: {
+ ':ObojoboDraft.Sections.Assessment:attemptHistory': []
+ },
+ variables: mockVariables
+ }
+ })
+
+ const mockVariableState = { mockVariableStateKey: 'mockVariableStateVal' }
+
+ VariableStore.getState.mockReturnValueOnce(mockVariableState)
+
+ const component = mount()
+
+ setTimeout(() => {
+ component.update()
+
+ VariableUtil.getValue.mockReturnValueOnce('mock-var-value')
+
+ // ordinarily this is an oboModel instance, but it doesn't matter for tests
+ const mockTextModel = { key: 'val' }
+ const mockEvent = { text: '' }
+
+ component.instance().getTextForVariable(mockEvent, '$nodeid1:variablename1', mockTextModel)
+ expect(VariableUtil.getValue).toHaveBeenCalledWith(
+ 'test-context',
+ mockVariableState,
+ 'nodeid1',
+ 'variablename1'
+ )
+ expect(VariableUtil.findValueWithModel).not.toHaveBeenCalled()
+
+ expect(mockEvent.text).toEqual('mock-var-value')
+
+ // bonus test to make sure event text is not overwritten if no value is found
+ VariableUtil.getValue.mockReturnValueOnce(null)
+ mockEvent.text = ''
+ expect(mockEvent.text).toEqual('')
+ component.instance().getTextForVariable(mockEvent, '$nodeid1:variablename1', mockTextModel)
+ expect(mockEvent.text).toEqual('')
+
+ done()
+ })
+ })
})
diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/__snapshots__/nav-store.test.js.snap b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/__snapshots__/nav-store.test.js.snap
index b8d2a1ddd1..05447ce750 100644
--- a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/__snapshots__/nav-store.test.js.snap
+++ b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/__snapshots__/nav-store.test.js.snap
@@ -52,6 +52,28 @@ Object {
"path": "whatever2",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
"type": "hidden",
},
],
@@ -68,6 +90,36 @@ Object {
"path": "whatever",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [
+ Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ "get": [Function],
+ "getRoot": [Function],
+ },
"type": "hidden",
}
`;
@@ -107,11 +159,63 @@ Object {
"path": "whatever",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [
+ Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ "get": [Function],
+ "getRoot": [Function],
+ },
"type": "hidden",
},
"path": "whatever2",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
"type": "hidden",
},
},
@@ -136,6 +240,28 @@ Object {
"path": "whatever2",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
"type": "hidden",
},
],
@@ -152,6 +278,36 @@ Object {
"path": "whatever",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [
+ Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ "get": [Function],
+ "getRoot": [Function],
+ },
"type": "hidden",
},
"9": Object {
@@ -185,11 +341,63 @@ Object {
"path": "whatever",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [
+ Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ "get": [Function],
+ "getRoot": [Function],
+ },
"type": "hidden",
},
"path": "whatever2",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
"type": "hidden",
},
},
@@ -225,11 +433,63 @@ Object {
"path": "whatever",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [
+ Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ "get": [Function],
+ "getRoot": [Function],
+ },
"type": "hidden",
},
"path": "whatever2",
"showChildren": true,
"showChildrenOnNavigation": true,
+ "sourceModel": Object {
+ "children": Object {
+ "models": Array [],
+ },
+ "get": [Function],
+ "getNavItem": [MockFunction] {
+ "calls": Array [
+ Array [
+ [Circular],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "id": "mockItem2",
+ "path": "whatever2",
+ },
+ },
+ ],
+ },
+ },
"type": "hidden",
},
},
diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/assessment-state-helpers.test.js b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/assessment-state-helpers.test.js
index a4cd983d7b..72479fd410 100644
--- a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/assessment-state-helpers.test.js
+++ b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/assessment-state-helpers.test.js
@@ -361,7 +361,8 @@ describe('AssessmentStateHelpers', () => {
value: {
assessmentId: 'mockAssessmentId',
attemptId: 'mockAttemptId',
- questions: [{ id: 'question1' }, { id: 'question2' }]
+ questions: [{ id: 'question1' }, { id: 'question2' }],
+ state: {}
}
}
@@ -396,7 +397,8 @@ describe('AssessmentStateHelpers', () => {
questionResponses: [
{ questionId: 'question1', response: true },
{ questionId: 'question2', response: { ids: ['mockNodeId1'] } }
- ]
+ ],
+ state: {}
}
}
diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/nav-store.test.js b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/nav-store.test.js
index 1b1ba9e1db..2c96e3e4d3 100644
--- a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/nav-store.test.js
+++ b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/nav-store.test.js
@@ -941,6 +941,7 @@ describe('NavStore', () => {
path: '',
showChildren: true,
showChildrenOnNavigation: true,
+ sourceModel: model,
type: 'hidden'
})
diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/variable-store.test.js b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/variable-store.test.js
new file mode 100644
index 0000000000..308171e6bd
--- /dev/null
+++ b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/variable-store.test.js
@@ -0,0 +1,151 @@
+const Common = require('../../../src/scripts/common/index').default
+
+const Dispatcher = Common.flux.Dispatcher
+jest.spyOn(Dispatcher, 'on')
+
+const VariableStore = require('../../../src/scripts/viewer/stores/variable-store').default
+
+// gotta hold on to this because beforeEach will clear it before the tests
+const eventCallbacks = Dispatcher.on.mock.calls[0][0]
+
+describe('VariableStore', () => {
+ const standardMockVariables = [
+ { id: 'mock-node-id1:mock-var-name1', value: 'mock-var-value1' },
+ { id: 'mock-node-id1:mock-var-name2', value: 'mock-var-value2' }
+ ]
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ VariableStore.setState()
+ })
+
+ test('registers events w/ dispatcher', () => {
+ expect(eventCallbacks).toEqual({ 'variables:addContext': expect.any(Function) })
+ })
+
+ const verifyInitialConditions = () => {
+ VariableStore.init(standardMockVariables)
+ expect(VariableStore.getState()).toEqual({
+ contexts: {
+ practice: {
+ values: {
+ 'mock-node-id1:mock-var-name1': 'mock-var-value1',
+ 'mock-node-id1:mock-var-name2': 'mock-var-value2'
+ },
+ varNamesByOwnerId: {
+ 'mock-node-id1': {
+ 'mock-var-name1': true,
+ 'mock-var-name2': true
+ }
+ }
+ }
+ }
+ })
+ }
+
+ test('init automatically creates a "practice" context with given values', () => {
+ verifyInitialConditions()
+ })
+
+ test('getContextState returns null for a context that does not exist', () => {
+ VariableStore.init(standardMockVariables)
+ expect(VariableStore.getContextState('invalid-context')).toBeNull()
+ })
+
+ test('hasContextState returns false for a context that does not exist', () => {
+ VariableStore.init(standardMockVariables)
+ expect(VariableStore.hasContextState('invalid-context')).toBe(false)
+ })
+
+ test('getOrCreateContextState will return a requested context if it exists', () => {
+ VariableStore.init(standardMockVariables)
+ expect(VariableStore.getOrCreateContextState('practice')).toEqual({
+ values: {
+ 'mock-node-id1:mock-var-name1': 'mock-var-value1',
+ 'mock-node-id1:mock-var-name2': 'mock-var-value2'
+ },
+ varNamesByOwnerId: {
+ 'mock-node-id1': {
+ 'mock-var-name1': true,
+ 'mock-var-name2': true
+ }
+ }
+ })
+ })
+
+ test('getOrCreateContextState will create a requested context if it does not exist', () => {
+ VariableStore.init(standardMockVariables)
+ expect(VariableStore.getOrCreateContextState('new-context')).toEqual({
+ values: {},
+ varNamesByOwnerId: {}
+ })
+ })
+
+ test('variables:addContext callback adds variables to an existing context', () => {
+ verifyInitialConditions()
+ eventCallbacks['variables:addContext']({
+ value: {
+ context: 'practice',
+ variables: [{ id: 'mock-node-id2:mock-var-name3', value: 'mock-var-value3' }]
+ }
+ })
+
+ expect(VariableStore.getState()).toEqual({
+ contexts: {
+ practice: {
+ values: {
+ 'mock-node-id1:mock-var-name1': 'mock-var-value1',
+ 'mock-node-id1:mock-var-name2': 'mock-var-value2',
+ 'mock-node-id2:mock-var-name3': 'mock-var-value3'
+ },
+ varNamesByOwnerId: {
+ 'mock-node-id1': {
+ 'mock-var-name1': true,
+ 'mock-var-name2': true
+ },
+ 'mock-node-id2': {
+ 'mock-var-name3': true
+ }
+ }
+ }
+ }
+ })
+ })
+
+ test('variables:addContext callback creates and adds variables to a new context', () => {
+ verifyInitialConditions()
+ eventCallbacks['variables:addContext']({
+ value: {
+ context: 'new-context',
+ variables: [{ id: 'mock-node-id2:mock-var-name3', value: 'mock-var-value3' }]
+ }
+ })
+
+ expect(VariableStore.getState()).toEqual({
+ contexts: {
+ practice: {
+ values: {
+ 'mock-node-id1:mock-var-name1': 'mock-var-value1',
+ 'mock-node-id1:mock-var-name2': 'mock-var-value2'
+ },
+ varNamesByOwnerId: {
+ 'mock-node-id1': {
+ 'mock-var-name1': true,
+ 'mock-var-name2': true
+ }
+ }
+ },
+ 'new-context': {
+ values: {
+ 'mock-node-id2:mock-var-name3': 'mock-var-value3'
+ },
+ varNamesByOwnerId: {
+ 'mock-node-id2': {
+ 'mock-var-name3': true
+ }
+ }
+ }
+ }
+ })
+ })
+})
diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/util/variable-util.test.js b/packages/app/obojobo-document-engine/__tests__/Viewer/util/variable-util.test.js
new file mode 100644
index 0000000000..b548e7c600
--- /dev/null
+++ b/packages/app/obojobo-document-engine/__tests__/Viewer/util/variable-util.test.js
@@ -0,0 +1,167 @@
+const VariableUtil = require('../../../src/scripts/viewer/util/variable-util').default
+
+describe('VariableUtil', () => {
+ const standardMockState = {
+ contexts: {
+ practice: {}
+ }
+ }
+
+ const makeTypicalMockStateChanges = () => {
+ const desiredContext = {
+ values: {
+ 'node-id:var-id-1': 'val1',
+ 'node-id:var-id-2': 'val2'
+ }
+ }
+ const mockState = {
+ contexts: {
+ practice: { ...desiredContext }
+ }
+ }
+ return [mockState, desiredContext]
+ }
+
+ test('getKey condenses a provided ownerID and variable name to a string', () => {
+ expect(VariableUtil.getKey('node-id', 'variable-name')).toBe('node-id:variable-name')
+ })
+
+ test('getStateForContext returns a requested context from a given state', () => {
+ const [mockState, desiredContext] = makeTypicalMockStateChanges()
+ expect(VariableUtil.getStateForContext(mockState, 'practice')).toEqual(desiredContext)
+ })
+
+ test('getStateForContext returns null if a requested context does not exist', () => {
+ expect(VariableUtil.getStateForContext(standardMockState, 'some-context')).toBeNull()
+ })
+
+ test('hasValue returns false if a given state lacks a given context', () => {
+ expect(VariableUtil.hasValue('some-context', standardMockState, '', '')).toBe(false)
+ })
+
+ test('hasValue returns false if a state context does not have the given variable owned by the given model id', () => {
+ const [mockState] = makeTypicalMockStateChanges()
+ expect(VariableUtil.hasValue('practice', mockState, 'some-other-node', 'var-id-1')).toBe(false)
+ })
+
+ test('hasValue returns true if a state context has the given variable owned by the given model id', () => {
+ const [mockState] = makeTypicalMockStateChanges()
+ expect(VariableUtil.hasValue('practice', mockState, 'node-id', 'var-id-1')).toBe(true)
+ })
+
+ test('getValue returns null if a given state lacks a given context', () => {
+ expect(VariableUtil.getValue('some-context', standardMockState, '', '')).toBeNull()
+ })
+
+ test('getValue returns the value of the requested variable', () => {
+ const [mockState] = makeTypicalMockStateChanges()
+ expect(VariableUtil.getValue('practice', mockState, 'node-id', 'var-id-1')).toBe('val1')
+ expect(VariableUtil.getValue('practice', mockState, 'node-id', 'var-id-2')).toBe('val2')
+ // even when the requested variable isn't defined
+ expect(VariableUtil.getValue('practice', mockState, 'node-id', 'var-id-3')).toBeUndefined()
+ })
+
+ // all getOwnerOfVariable calls will also call getStateForContext, which we can't really mock
+ // it is what it is
+ test('getOwnerOfVariable returns null if getStateForContext is falsy', () => {
+ expect(
+ VariableUtil.getOwnerOfVariable('some-context', standardMockState, null, null)
+ ).toBeNull()
+ })
+
+ test('getOwnerOfVariable returns the given model if it owns the requested variable', () => {
+ const [mockState] = makeTypicalMockStateChanges()
+ // ordinarily this would be an oboNode instance - .get would be just one method available
+ // in the case of this test, it's the only one we care about
+ const mockNodeGet = jest.fn().mockReturnValue('node-id')
+ const mockNode = {
+ get: mockNodeGet
+ }
+ expect(VariableUtil.getOwnerOfVariable('practice', mockState, mockNode, 'var-id-1')).toEqual(
+ mockNode
+ )
+ expect(mockNodeGet).toHaveBeenCalledTimes(1)
+ expect(mockNodeGet).toHaveBeenCalledWith('id')
+ })
+
+ test("getOwnerOfVariable returns the given model's parent if the parent owns the requested variable", () => {
+ const [mockState] = makeTypicalMockStateChanges()
+ // ordinarily this would be an oboNode instance - .get would be just one method available
+ // in the case of this test, it's the only one we care about
+ const mockNodeGet = jest.fn().mockReturnValue('some-other-node-id')
+ const mockParentNodeGet = jest.fn().mockReturnValue('node-id')
+ const mockParentNode = {
+ get: mockParentNodeGet
+ }
+ const mockNode = {
+ get: mockNodeGet,
+ parent: mockParentNode
+ }
+ expect(VariableUtil.getOwnerOfVariable('practice', mockState, mockNode, 'var-id-1')).toEqual(
+ mockParentNode
+ )
+ expect(mockNodeGet).toHaveBeenCalledTimes(1)
+ expect(mockNodeGet).toHaveBeenCalledWith('id')
+ expect(mockParentNodeGet).toHaveBeenCalledTimes(1)
+ expect(mockParentNodeGet).toHaveBeenCalledWith('id')
+ })
+
+ test('getOwnerOfVariable returns null if neither the given model or its parent own the requested variable', () => {
+ const [mockState] = makeTypicalMockStateChanges()
+ // ordinarily this would be an oboNode instance - .get would be just one method available
+ // in the case of this test, it's the only one we care about
+ const mockNodeGet = jest.fn().mockReturnValue('some-other-node-id')
+ const mockParentNodeGet = jest.fn().mockReturnValue('some-other-parent-node-id')
+ const mockParentNode = {
+ get: mockParentNodeGet
+ }
+ const mockNode = {
+ get: mockNodeGet,
+ parent: mockParentNode
+ }
+ expect(VariableUtil.getOwnerOfVariable('practice', mockState, mockNode, 'var-id-1')).toBeNull()
+ expect(mockNodeGet).toHaveBeenCalledTimes(1)
+ expect(mockNodeGet).toHaveBeenCalledWith('id')
+ expect(mockParentNodeGet).toHaveBeenCalledTimes(1)
+ expect(mockParentNodeGet).toHaveBeenCalledWith('id')
+ })
+
+ // all findValueWithModel calls will also call getStateForContext and getOwnerOfVariable and getValue
+ // we can't mock those, but it is what it is
+ test('findValueWithModel returns null if getStateForContext is falsy', () => {
+ expect(VariableUtil.findValueWithModel('some-context', standardMockState, {}, '')).toBeNull()
+ })
+
+ test('findValueWithModel returns null if there is no owner for the requested variable', () => {
+ const [mockState] = makeTypicalMockStateChanges()
+ const mockNodeGet = jest.fn().mockReturnValue('some-other-node-id')
+ const mockNode = {
+ get: mockNodeGet
+ }
+ expect(VariableUtil.findValueWithModel('practice', mockState, mockNode, 'var-id-1')).toBeNull()
+ })
+
+ test('findValueWithModel returns the value of the requested variable correctly', () => {
+ const [mockState] = makeTypicalMockStateChanges()
+ const mockNodeGet = jest.fn().mockReturnValue('node-id')
+ const mockNode = {
+ get: mockNodeGet
+ }
+ expect(VariableUtil.findValueWithModel('practice', mockState, mockNode, 'var-id-1')).toEqual(
+ 'val1'
+ )
+ })
+
+ // all getVariableStateSummary calls will also call getStateForContext, which we can't really mock
+ // it is what it is
+ test('getVariableStateSummary returns null if getStateForContext is falsy', () => {
+ expect(VariableUtil.getVariableStateSummary('some-context', standardMockState)).toBeNull()
+ })
+
+ test('returns the variable values available in the state for a requested context', () => {
+ const [mockState, desiredContext] = makeTypicalMockStateChanges()
+ expect(VariableUtil.getVariableStateSummary('practice', mockState)).toEqual(
+ desiredContext.values
+ )
+ })
+})
diff --git a/packages/app/obojobo-document-engine/__tests__/common/models/obo-model.test.js b/packages/app/obojobo-document-engine/__tests__/common/models/obo-model.test.js
index b601218c65..fe68bd6a36 100644
--- a/packages/app/obojobo-document-engine/__tests__/common/models/obo-model.test.js
+++ b/packages/app/obojobo-document-engine/__tests__/common/models/obo-model.test.js
@@ -39,6 +39,7 @@ describe('OboModel', () => {
expect(o.children.models.length).toBe(0)
expect(o.triggers).toEqual([])
expect(o.objectives).toEqual([])
+ expect(o.variables).toEqual([])
expect(o.title).toBe(null)
expect(o.modelState).toEqual({
dirty: false,
@@ -60,12 +61,14 @@ describe('OboModel', () => {
triggers: [{ passedInTrigger: 1 }],
title: 'passedInTitle',
customContent: 'example',
- objectives: [{ objectiveId: 'mock-objective' }]
+ objectives: [{ objectiveId: 'mock-objective' }],
+ variables: [{ name: 'mock-var', type: 'mock-var-type' }]
}
})
expect(o.triggers).toEqual([{ passedInTrigger: 1 }])
expect(o.objectives).toEqual([{ objectiveId: 'mock-objective' }])
+ expect(o.variables).toEqual([{ name: 'mock-var', type: 'mock-var-type' }])
expect(o.title).toEqual('passedInTitle')
expect(o.id).toEqual('passedInId')
expect(o.get('content').customContent).toEqual('example')
diff --git a/packages/app/obojobo-document-engine/__tests__/common/util/__snapshots__/feature-flags.test.js.snap b/packages/app/obojobo-document-engine/__tests__/common/util/__snapshots__/feature-flags.test.js.snap
new file mode 100644
index 0000000000..c6fc6d8cf1
--- /dev/null
+++ b/packages/app/obojobo-document-engine/__tests__/common/util/__snapshots__/feature-flags.test.js.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FeatureFlags Constructs as expected 1`] = `
+FeatureFlags {
+ "ENABLED": "enabled",
+}
+`;
diff --git a/packages/app/obojobo-document-engine/__tests__/common/util/feature-flags.test.js b/packages/app/obojobo-document-engine/__tests__/common/util/feature-flags.test.js
new file mode 100644
index 0000000000..f17d92d25e
--- /dev/null
+++ b/packages/app/obojobo-document-engine/__tests__/common/util/feature-flags.test.js
@@ -0,0 +1,115 @@
+import FeatureFlags from '../../../src/scripts/common/util/feature-flags'
+import mockConsole from 'jest-mock-console'
+
+describe('FeatureFlags', () => {
+ let restoreConsole
+
+ beforeEach(() => {
+ restoreConsole = mockConsole('error')
+ })
+
+ afterEach(() => {
+ FeatureFlags.clearAll()
+ restoreConsole()
+ })
+
+ test('Constructs as expected', () => {
+ expect(FeatureFlags).toMatchSnapshot()
+ })
+
+ test('set sets the string value into localStorage', () => {
+ expect(window.localStorage['obojobo:flags']).toBeUndefined()
+ expect(FeatureFlags.set('mockName', 123)).toBe(true)
+ expect(JSON.parse(window.localStorage['obojobo:flags'])).toEqual({ mockName: '123' })
+ })
+
+ test('get gets the value from localStorage', () => {
+ FeatureFlags.set('mockName', 'mockValue')
+ expect(FeatureFlags.get('mockName')).toBe('mockValue')
+ })
+
+ test('is returns true if the feature flag at flagName is equal to the given value', () => {
+ FeatureFlags.set('mockName', 123)
+ expect(FeatureFlags.is('someOtherKey', '123')).toBe(false)
+ expect(FeatureFlags.is('mockName', 123)).toBe(true)
+ expect(FeatureFlags.is('mockName', '123')).toBe(true)
+ })
+
+ test('list returns the contents of all feature flags', () => {
+ expect(FeatureFlags.list()).toEqual({})
+ FeatureFlags.set('mockName', 'mockValue')
+ expect(FeatureFlags.list()).toEqual({ mockName: 'mockValue' })
+ })
+
+ test('clear will clear out a feature flag', () => {
+ FeatureFlags.set('alpha', '42')
+ FeatureFlags.set('beta', '0')
+ expect(JSON.parse(window.localStorage['obojobo:flags'])).toEqual({ alpha: '42', beta: '0' })
+
+ FeatureFlags.clear('alpha')
+ expect(JSON.parse(window.localStorage['obojobo:flags'])).toEqual({ beta: '0' })
+ expect(FeatureFlags.list()).toEqual({ beta: '0' })
+ })
+
+ test('clearAll will remove all feature flags', () => {
+ FeatureFlags.set('alpha', '42')
+ FeatureFlags.set('beta', '0')
+ expect(JSON.parse(window.localStorage['obojobo:flags'])).toEqual({ alpha: '42', beta: '0' })
+
+ FeatureFlags.clearAll()
+ expect(window.localStorage['obojobo:flags']).toBeUndefined()
+ expect(FeatureFlags.list()).toEqual({})
+ })
+
+ test('Writing a bad feature flag fails as expected, deletes all flags', () => {
+ FeatureFlags.set('alpha', '42')
+ FeatureFlags.set('beta', '0')
+ expect(JSON.parse(window.localStorage['obojobo:flags'])).toEqual({ alpha: '42', beta: '0' })
+
+ const originalJSON = JSON
+ window.JSON = {
+ stringify: () => {
+ throw 'mockError'
+ }
+ }
+
+ expect(FeatureFlags.set('gamma', '0')).toBe(false)
+
+ expect(console.error).toHaveBeenCalledWith('Unable to save feature flags: mockError')
+ expect(FeatureFlags.list()).toEqual({})
+ expect(window.localStorage['obojobo:flags']).toBeUndefined()
+
+ window.JSON = originalJSON
+ })
+
+ test('Clearing a flag deletes all flags if there is an error', () => {
+ FeatureFlags.set('alpha', '42')
+ FeatureFlags.set('beta', '0')
+
+ const originalJSON = JSON
+ window.JSON = {
+ stringify: () => {
+ throw 'mockError'
+ }
+ }
+
+ expect(FeatureFlags.clear('gamma')).toBe(false)
+
+ expect(console.error).toHaveBeenCalledWith('Unable to save feature flags: mockError')
+ expect(FeatureFlags.list()).toEqual({})
+ expect(window.localStorage['obojobo:flags']).toBeUndefined()
+
+ window.JSON = originalJSON
+ })
+
+ test('When getting a feature flag fails all flags are deleted', () => {
+ window.localStorage['obojobo:flags'] = '{This will cause a JSON.parse error!'
+
+ FeatureFlags.constructor()
+
+ expect(console.error).toHaveBeenCalledWith(
+ 'Unable to parse feature flags: SyntaxError: Unexpected token T in JSON at position 1'
+ )
+ expect(window.localStorage['obojobo:flags']).toBeUndefined()
+ })
+})
diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/navigation/__snapshots__/more-info-box.test.js.snap b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/navigation/__snapshots__/more-info-box.test.js.snap
index 34703ed1b8..4ee2845ad0 100644
--- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/navigation/__snapshots__/more-info-box.test.js.snap
+++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/navigation/__snapshots__/more-info-box.test.js.snap
@@ -82,3 +82,9 @@ exports[`MoreInfoBox More Info Box with no button bar 1`] = `"
Objectives:
Triggers:
"`;
exports[`MoreInfoBox More Info Box with triggers 1`] = `"
Objectives:
Triggers:mockTrigger, mockSecondTrigger
"`;
+
+exports[`MoreInfoBox More Info Box with variables - feature flag enabled 1`] = `"
Objectives:
Triggers:
Variables:$mockVar1, $mockVar2
"`;
+
+exports[`MoreInfoBox More Info Box with variables - feature flag not enabled 1`] = `"
Objectives:
Triggers:
"`;
+
+exports[`MoreInfoBox More Info Box without variables - feature flag enabled 1`] = `"
Objectives:
Triggers:
Variables:
"`;
diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/navigation/more-info-box.test.js b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/navigation/more-info-box.test.js
index 451fba546e..71d087311b 100644
--- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/navigation/more-info-box.test.js
+++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/navigation/more-info-box.test.js
@@ -5,6 +5,9 @@ import rtr from 'react-test-renderer'
import MoreInfoBox from 'src/scripts/oboeditor/components/navigation/more-info-box'
import ObjectiveProvider from 'src/scripts/oboeditor/components//objectives/objective-provider'
+import FeatureFlags from 'src/scripts/common/util/feature-flags'
+jest.mock('src/scripts/common/util/feature-flags')
+
import ClipboardUtil from 'src/scripts/oboeditor/util/clipboard-util'
jest.mock('src/scripts/oboeditor/util/clipboard-util')
import ModalUtil from 'src/scripts/common/util/modal-util'
@@ -154,6 +157,89 @@ describe('MoreInfoBox', () => {
expect(component.html()).toMatchSnapshot()
})
+ test('More Info Box without variables - feature flag enabled', () => {
+ FeatureFlags.is = jest.fn().mockReturnValueOnce(true)
+
+ const component = mount(
+
+ )
+
+ component
+ .find('button')
+ .at(0)
+ .simulate('click')
+
+ const targets = component.find('.triggers')
+ expect(targets.length).toBe(2)
+ expect(targets.at(0).text()).toEqual('Triggers:')
+ expect(targets.at(1).text()).toEqual('Variables:')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('More Info Box with variables - feature flag not enabled', () => {
+ const component = mount(
+
+ )
+
+ component
+ .find('button')
+ .at(0)
+ .simulate('click')
+
+ const targets = component.find('.triggers')
+ expect(targets.length).toBe(1)
+ expect(targets.at(0).text()).toEqual('Triggers:')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('More Info Box with variables - feature flag enabled', () => {
+ FeatureFlags.is = jest.fn().mockReturnValueOnce(true)
+
+ const component = mount(
+
+ )
+
+ component
+ .find('button')
+ .at(0)
+ .simulate('click')
+
+ const targets = component.find('.triggers')
+ expect(targets.length).toBe(2)
+ expect(targets.at(0).text()).toEqual('Triggers:')
+ // we happen to know what this should be based on the mocks, but this is kind of magical
+ expect(targets.at(1).text()).toEqual('Variables:$mockVar1, $mockVar2')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
test('More Info Box with contentDescriptions', () => {
const component = mount(
{
expect(ModalUtil.show).toHaveBeenCalled()
})
+ test('More Info Box opens the showVariablesModal', () => {
+ FeatureFlags.is = jest.fn().mockReturnValueOnce(true)
+
+ const component = mount(
+
+ )
+
+ component
+ .find('button')
+ .at(0)
+ .simulate('click')
+
+ component
+ .find('button')
+ .at(5)
+ .simulate('click')
+
+ expect(ModalUtil.show).toHaveBeenCalled()
+ })
+
test('More Info Box opens the showTriggersModal', () => {
const component = mount(
$mock_var
Static value 3"`;
+
+exports[`VariableBlock VariableBlock with type "pick-list" - when choose > 1, item should be plural 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "pick-list" - when chooseMax = choseMin = 1, item should be singular 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "pick-list" 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "pick-list" with no default value 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "pick-list" with no default value 2`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "pick-one" 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "pick-one" with no default value 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-list" - when sizeMin = sizeMax = 1, item should be singular 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-list" - when sizeMin > 1, item should be plural 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-list" 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-list" with no default value 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-number" 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-number" with no default value 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-sequence" - when size > 1, item should be plural 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-sequence" - when sizeMin = sizeMax = 1, item should be singular 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-sequence" 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "random-sequence" with no default value 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "static-list" 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "static-list" with no default value 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "static-value" 1`] = `""`;
+
+exports[`VariableBlock VariableBlock with type "static-value" and no default value 1`] = `""`;
diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/new-variable/__snapshots__/new-variable.test.js.snap b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/new-variable/__snapshots__/new-variable.test.js.snap
new file mode 100644
index 0000000000..3f4f3b7e4d
--- /dev/null
+++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/new-variable/__snapshots__/new-variable.test.js.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`VariableValue VariableValue calls "addVariable" on mouse click 1`] = `"
"`;
+
+exports[`Variable Properties VariableProperty node with invalid variable 1`] = `""`;
+
+exports[`Variable Properties VariableProperty node without default name or type 1`] = `"
Name:Type:
Duplicate:
"`;
diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-property/__snapshots__/variable-value.test.js.snap b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-property/__snapshots__/variable-value.test.js.snap
new file mode 100644
index 0000000000..64b72ba0da
--- /dev/null
+++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-property/__snapshots__/variable-value.test.js.snap
@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`VariableValue VariableValue 1`] = `"
Value:
"`;
+
+exports[`VariableValue VariableValue component type "pick-list" 1`] = `"
Values:
Enter values, separating each value with a comma (eg. '1, 2, 3')
Choose: to
Order:
"`;
+
+exports[`VariableValue VariableValue component type "pick-list" without default value 1`] = `"
Values:
Enter values, separating each value with a comma (eg. '1, 2, 3')
Choose: to
Order:
"`;
+
+exports[`VariableValue VariableValue component type "pick-one" 1`] = `"
Values:
Enter values, separating each value with a comma (eg. '1, 2, 3')
"`;
+
+exports[`VariableValue VariableValue component type "pick-one" without default value 1`] = `"
Values:
Enter values, separating each value with a comma (eg. '1, 2, 3')
"`;
+
+exports[`VariableValue VariableValue component type "random-list" 1`] = `"
List Size: toNo duplicate: Min Value: Max Value: Decimal places: to
"`;
+
+exports[`VariableValue VariableValue component type "random-list" without default value 1`] = `"
List Size: toNo duplicate: Min Value: Max Value: Decimal places: to
"`;
+
+exports[`VariableValue VariableValue component type "random-number" 1`] = `"
Min Value: Max Value: Decimal places: to
"`;
+
+exports[`VariableValue VariableValue component type "random-number" without default value 1`] = `"
Min Value: Max Value: Decimal places: to
"`;
+
+exports[`VariableValue VariableValue component type "random-sequence" 1`] = `"
List Size: toFirst value: Series type: Step by:
"`;
+
+exports[`VariableValue VariableValue component type "random-sequence" without default value 1`] = `"
List Size: toFirst value: Series type: Step by:
"`;
+
+exports[`VariableValue VariableValue component type "static-list" 1`] = `"
Values:
Enter values, separating each value with a comma (eg. '1, 2, 3')
"`;
+
+exports[`VariableValue VariableValue component type "static-value" 1`] = `"
Value:
"`;
+
+exports[`VariableValue VariableValue component type "static-value" without default value 1`] = `"
Value:
"`;
+
+exports[`VariableValue VariableValue component type "static-value" without default value 2`] = `"
Values:
Enter values, separating each value with a comma (eg. '1, 2, 3')
"`;
+
+exports[`VariableValue VariableValue component without valid type 1`] = `null`;
diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-property/variable-property.test.js b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-property/variable-property.test.js
new file mode 100644
index 0000000000..bfa96aca79
--- /dev/null
+++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-property/variable-property.test.js
@@ -0,0 +1,177 @@
+import React from 'react'
+import { shallow, mount } from 'enzyme'
+
+import VariableProperty from '../../../../../src/scripts/oboeditor/components/variables/variable-property/variable-property'
+
+describe('Variable Properties', () => {
+ test('VariableProperty node', () => {
+ const variable = {
+ name: 'static_var',
+ type: 'static-value',
+ value: '3'
+ }
+
+ const deleteVariable = jest.fn()
+ const component = shallow(
+
+ )
+ // no errors, shouldn't show error class
+ expect(
+ component
+ .find('.variable-property')
+ .at(0)
+ .childAt(0)
+ .props()
+ .className.trim()
+ ).toEqual('group-item')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableProperty node with invalid variable', () => {
+ const variable = null
+
+ const deleteVariable = jest.fn()
+ const component = shallow(
+
+ )
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableProperty node without default name or type', () => {
+ const variable = {}
+ const deleteVariable = jest.fn()
+ const component = shallow(
+
+ )
+ expect(
+ component
+ .find('input')
+ .at(0)
+ .props().value
+ ).toEqual('')
+ expect(
+ component
+ .find('select')
+ .at(0)
+ .props().value
+ ).toEqual('')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableProperty clicks "delete" will call "deleteVariable"', () => {
+ global.confirm = () => true
+
+ const variable = {
+ name: 'static_var',
+ type: 'static-value',
+ value: '3'
+ }
+
+ const deleteVariable = jest.fn()
+ const component = mount(
+
+ )
+
+ component
+ .find('button')
+ .at(1)
+ .simulate('click')
+
+ expect(deleteVariable).toHaveBeenCalled()
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableProperty does not call "deleteVariable" when user cancels', () => {
+ global.confirm = () => false
+
+ const variable = {
+ name: 'static_var',
+ type: 'static-value',
+ value: '3'
+ }
+
+ const deleteVariable = jest.fn()
+ const component = mount(
+
+ )
+
+ component
+ .find('button')
+ .at(1)
+ .simulate('click')
+
+ expect(deleteVariable).not.toHaveBeenCalled()
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableProperty calls onChange when input changes', () => {
+ const variable = {
+ name: 'static_var',
+ type: 'static-value',
+ value: '3'
+ }
+
+ const onChange = jest.fn()
+ const component = mount()
+
+ component
+ .find('input')
+ .at(0)
+ .simulate('change', { target: { value: '333' } })
+
+ expect(onChange).toHaveBeenCalled()
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableProperty renders with errors, but not name', () => {
+ const variable = {
+ name: 'static_var',
+ type: 'static-value',
+ value: '3',
+ errors: {
+ prop: true
+ }
+ }
+
+ const deleteVariable = jest.fn()
+ const component = shallow(
+
+ )
+ // no errors, shouldn't show error class
+ expect(
+ component
+ .find('.variable-property')
+ .at(0)
+ .childAt(0)
+ .props()
+ .className.trim()
+ ).toEqual('group-item')
+ })
+
+ test('VariableProperty renders with errors name error', () => {
+ const variable = {
+ name: 'static_var',
+ type: 'static-value',
+ value: '3',
+ errors: {
+ prop: true,
+ name: true
+ }
+ }
+
+ const deleteVariable = jest.fn()
+ const component = shallow(
+
+ )
+ // no errors, shouldn't show error class
+ expect(
+ component
+ .find('.variable-property')
+ .at(0)
+ .childAt(0)
+ .props().className
+ ).toEqual('group-item has-error')
+ })
+})
diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-property/variable-value.test.js b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-property/variable-value.test.js
new file mode 100644
index 0000000000..ee25c817ec
--- /dev/null
+++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-property/variable-value.test.js
@@ -0,0 +1,561 @@
+import React from 'react'
+import { shallow } from 'enzyme'
+
+import VariableValue from '../../../../../src/scripts/oboeditor/components/variables/variable-property/variable-value'
+
+describe('VariableValue', () => {
+ test('VariableValue', () => {
+ const variable = {
+ name: 'static_var',
+ type: 'static-value',
+ value: '3'
+ }
+
+ const component = shallow()
+ // inputs should not indicate errors - static-value types are inputs, not selects
+ expect(
+ component
+ .find('.variable-values--group input')
+ .at(0)
+ .props().className
+ ).toBe('variable-property--input-item')
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component without valid type', () => {
+ const variable = {
+ name: 'g',
+ type: 'mock-type'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.length).toEqual(0)
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "static-value"', () => {
+ const variable = {
+ name: 'static_var',
+ type: 'static-value',
+ value: '3'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('3')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "static-value" without default value', () => {
+ const variable = {
+ name: 'static_var',
+ type: 'static-value'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "static-list"', () => {
+ const variable = {
+ name: 'c',
+ type: 'static-list',
+ value: '4, 5, 6'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('4, 5, 6')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "static-value" without default value', () => {
+ const variable = {
+ name: 'c',
+ type: 'static-list'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "random-number"', () => {
+ const variable = {
+ name: 'b',
+ type: 'random-number',
+ valueMax: '10',
+ valueMin: '3',
+ decimalPlacesMax: '4',
+ decimalPlacesMin: '4'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('3')
+ expect(inputs.at(1).props().value).toEqual('10')
+ expect(inputs.at(2).props().value).toEqual('4')
+ expect(inputs.at(3).props().value).toEqual('4')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "random-number" without default value', () => {
+ const variable = {
+ name: 'b',
+ type: 'random-number'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('')
+ expect(inputs.at(1).props().value).toEqual('')
+ expect(inputs.at(2).props().value).toEqual('')
+ expect(inputs.at(3).props().value).toEqual('')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "random-list"', () => {
+ const variable = {
+ name: 'd',
+ type: 'random-list',
+ unique: true,
+ sizeMax: '5',
+ sizeMin: '3',
+ valueMax: '10',
+ valueMin: '3',
+ decimalPlacesMax: '1',
+ decimalPlacesMin: '1'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual(variable.sizeMin)
+ expect(inputs.at(1).props().value).toEqual(variable.sizeMax)
+ expect(inputs.at(3).props().value).toEqual(variable.valueMin)
+ expect(inputs.at(4).props().value).toEqual(variable.valueMax)
+ expect(inputs.at(5).props().value).toEqual(variable.decimalPlacesMin)
+ expect(inputs.at(6).props().value).toEqual(variable.decimalPlacesMax)
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "random-list" without default value', () => {
+ const variable = {
+ name: 'd',
+ type: 'random-list'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('')
+ expect(inputs.at(1).props().value).toEqual('')
+ expect(inputs.at(3).props().value).toEqual('')
+ expect(inputs.at(4).props().value).toEqual('')
+ expect(inputs.at(5).props().value).toEqual('')
+ expect(inputs.at(6).props().value).toEqual('')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "random-sequence"', () => {
+ const variable = {
+ name: 'e',
+ step: '1.1',
+ type: 'random-sequence',
+ sizeMin: '1',
+ sizeMax: '10',
+ start: '10',
+ seriesType: 'geometric'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ // inputs should not indicate errors - random-sequence types are selects, not inputs
+ expect(inputs.at(0).props().value).toEqual(variable.sizeMin)
+ expect(inputs.at(0).props().className).toBe('variable-property--input-item')
+ expect(inputs.at(1).props().value).toEqual(variable.sizeMax)
+ expect(inputs.at(1).props().className).toBe('variable-property--input-item')
+ expect(inputs.at(2).props().value).toEqual(variable.start)
+ expect(inputs.at(2).props().className).toBe('variable-property--input-item')
+ expect(inputs.at(3).props().value).toEqual(variable.step)
+ expect(inputs.at(3).props().className).toBe('variable-property--input-item')
+ expect(
+ component
+ .find('select')
+ .at(0)
+ .props().className
+ ).toBe('variable-property--select-item')
+ expect(component.find('.invalid-value-warning').length).toBe(0)
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "random-sequence" without default value', () => {
+ const variable = {
+ name: 'e',
+ type: 'random-sequence'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('')
+ expect(inputs.at(1).props().value).toEqual('')
+ expect(inputs.at(2).props().value).toEqual('')
+ expect(inputs.at(3).props().value).toEqual('')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "pick-one"', () => {
+ const variable = {
+ name: 'f',
+ type: 'pick-one',
+ value: '3, 4, 5, 3, 5'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual(variable.value)
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "pick-one" without default value', () => {
+ const variable = {
+ name: 'f',
+ type: 'pick-one'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "pick-list"', () => {
+ const variable = {
+ name: 'g',
+ type: 'pick-list',
+ value: '33, 3, 4, 55, 23, 444',
+ ordered: false,
+ chooseMax: '40',
+ chooseMin: '5'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual(variable.value)
+ expect(inputs.at(1).props().value).toEqual(variable.chooseMin)
+ expect(inputs.at(2).props().value).toEqual(variable.chooseMax)
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('VariableValue component type "pick-list" without default value', () => {
+ const variable = {
+ name: 'g',
+ type: 'pick-list'
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ expect(inputs.at(0).props().value).toEqual('')
+ expect(inputs.at(1).props().value).toEqual('')
+ expect(inputs.at(2).props().value).toEqual('')
+
+ expect(component.html()).toMatchSnapshot()
+ })
+
+ test('onBlurMin (valueMin, valueMax) - when both values are equal and the first value is changed then the second value should match the first', () => {
+ const variable = {
+ name: 'g',
+ type: 'random-number',
+ valueMax: '5',
+ valueMin: '5'
+ }
+
+ const onChange = jest.fn()
+ const component = shallow()
+ const inputs = component.find('input')
+
+ inputs.at(0).simulate('blur', { target: { name: 'valueMin', value: '10' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'valueMin', value: '10' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'valueMax', value: '10' } })
+ })
+
+ test('onBlurMin (decimalPlacesMin, decimalPlacesMax) - when both values are equal and the first value is changed then the second value should match the first', () => {
+ const variable = {
+ name: 'b',
+ type: 'random-number',
+ valueMax: '10',
+ valueMin: '3',
+ decimalPlacesMax: '4',
+ decimalPlacesMin: '4'
+ }
+
+ const onChange = jest.fn()
+ const component = shallow()
+ const inputs = component.find('input')
+
+ inputs.at(2).simulate('blur', { target: { name: 'decimalPlacesMin', value: '10' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'decimalPlacesMin', value: '10' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'decimalPlacesMax', value: '10' } })
+ })
+
+ test('onBlurMin (sizeMin, sizeMax) - when both values are equal and the first value is changed then the second value should match the first', () => {
+ const variable = {
+ name: 'e',
+ step: '1.1',
+ type: 'random-sequence',
+ sizeMax: '3',
+ sizeMin: '3',
+ valueMax: '100',
+ valueMin: '1',
+ seriesType: 'geometric'
+ }
+
+ const onChange = jest.fn()
+ const component = shallow()
+ const inputs = component.find('input')
+
+ inputs.at(2).simulate('blur', { target: { name: 'sizeMin', value: '0' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMin', value: '0' } })
+ expect(onChange).not.toHaveBeenCalledWith({ target: { name: 'sizeMax', value: '0' } })
+
+ inputs.at(2).simulate('blur', { target: { name: 'sizeMin', value: '7' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMin', value: '7' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMax', value: '7' } })
+ })
+
+ test('onBlurMin (chooseMin, chooseMax) - when both values are equal and the first value is changed then the second value should match the first', () => {
+ const variable = {
+ name: 'g',
+ type: 'pick-list',
+ value: '33, 3, 4, 55, 23, 444',
+ ordered: 'false',
+ chooseMax: '5',
+ chooseMin: '5'
+ }
+
+ const onChange = jest.fn()
+ const component = shallow()
+ const inputs = component.find('input')
+
+ inputs.at(1).simulate('blur', { target: { name: 'chooseMin', value: '0' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'chooseMin', value: '0' } })
+ expect(onChange).not.toHaveBeenCalledWith({ target: { name: 'chooseMax', value: '0' } })
+
+ inputs.at(1).simulate('blur', { target: { name: 'chooseMin', value: '7' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'chooseMin', value: '7' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'chooseMax', value: '7' } })
+ })
+
+ test('onChangeMin - if second value does not exist, it should match the first', () => {
+ const variable = {
+ name: 'e',
+ step: '1.1',
+ type: 'random-sequence',
+ sizeMin: '3',
+ valueMax: '100',
+ valueMin: '1',
+ seriesType: 'geometric'
+ }
+
+ const onChange = jest.fn()
+ const component = shallow()
+ const inputs = component.find('input')
+
+ inputs.at(2).simulate('blur', { target: { name: 'sizeMin', value: '7' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMin', value: '7' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMax', value: '7' } })
+ })
+
+ test('onBlurMax (valueMin, valueMax) - when both values are equal and the second value is decreased the first value should match the second. Increasing the second value should not update the first', () => {
+ const variable = {
+ name: 'g',
+ type: 'random-number',
+ valueMax: '15',
+ valueMin: '15'
+ }
+
+ const onChange = jest.fn()
+ const component = shallow()
+ const inputs = component.find('input')
+
+ inputs.at(1).simulate('blur', { target: { name: 'sizeMax', value: '40' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMax', value: '40' } })
+ expect(onChange).not.toHaveBeenCalledWith({ target: { name: 'sizeMin', value: '40' } })
+
+ inputs.at(1).simulate('blur', { target: { name: 'valueMax', value: '1' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'valueMin', value: '1' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'valueMax', value: '1' } })
+ })
+
+ test('onBlurMax (decimalPlacesMin, decimalPlacesMax) - When both values are equal and the second value is decreased the first value should match the second. Increasing the second value should not update the first', () => {
+ const variable = {
+ name: 'b',
+ type: 'random-number',
+ decimalPlacesMax: '4',
+ decimalPlacesMin: '4'
+ }
+
+ const onChange = jest.fn()
+ const component = shallow()
+ const inputs = component.find('input')
+
+ inputs.at(3).simulate('blur', { target: { name: 'sizeMax', value: '30' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMax', value: '30' } })
+ expect(onChange).not.toHaveBeenCalledWith({ target: { name: 'sizeMin', value: '30' } })
+
+ inputs.at(3).simulate('blur', { target: { name: 'decimalPlacesMax', value: '2' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'decimalPlacesMax', value: '2' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'decimalPlacesMin', value: '2' } })
+ })
+
+ test('onBlurMax (sizeMin, sizeMax) - When both values are equal and the second value is decreased the first value should match the second. Increasing the second value should not update the first', () => {
+ const variable = {
+ name: 'd',
+ type: 'random-list',
+ unique: true,
+ sizeMax: '3',
+ sizeMin: '3',
+ valueMax: '10',
+ valueMin: '3',
+ decimalPlacesMax: '1',
+ decimalPlacesMin: '1'
+ }
+
+ const onChange = jest.fn()
+ const component = shallow()
+ const inputs = component.find('input')
+
+ inputs.at(1).simulate('blur', { target: { name: 'sizeMax', value: '10' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMax', value: '10' } })
+ expect(onChange).not.toHaveBeenCalledWith({ target: { name: 'sizeMin', value: '10' } })
+
+ inputs.at(1).simulate('blur', { target: { name: 'sizeMax', value: '1' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMin', value: '1' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'sizeMax', value: '1' } })
+ })
+
+ test('onBlurMax (chooseMin, chooseMax) - When both values are equal and the second value is decreased the first value should match the second. Increasing the second value should not update the first', () => {
+ const variable = {
+ name: 'g',
+ type: 'pick-list',
+ value: '33, 3, 4, 55, 23, 444',
+ ordered: 'false',
+ chooseMax: '5',
+ chooseMin: '5'
+ }
+
+ const onChange = jest.fn()
+ const component = shallow()
+ const inputs = component.find('input')
+
+ inputs.at(2).simulate('blur', { target: { name: 'chooseMax', value: '10' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'chooseMax', value: '10' } })
+ expect(onChange).not.toHaveBeenCalledWith({ target: { name: 'chooseMin', value: '10' } })
+
+ inputs.at(2).simulate('blur', { target: { name: 'chooseMax', value: '1' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'chooseMin', value: '1' } })
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'chooseMax', value: '1' } })
+ })
+
+ test('renders with errors, no type match', () => {
+ const variable = {
+ name: 'e',
+ step: '1.1',
+ type: 'random-sequence',
+ sizeMin: '1',
+ sizeMax: '10',
+ start: '10',
+ seriesType: 'geometric',
+ errors: {
+ irrelevantProp: true
+ }
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ // inputs should not indicate errors - random-sequence types are selects, not inputs
+ expect(inputs.at(0).props().value).toEqual(variable.sizeMin)
+ expect(inputs.at(0).props().className).toBe('variable-property--input-item')
+ expect(inputs.at(1).props().value).toEqual(variable.sizeMax)
+ expect(inputs.at(1).props().className).toBe('variable-property--input-item')
+ expect(inputs.at(2).props().value).toEqual(variable.start)
+ expect(inputs.at(2).props().className).toBe('variable-property--input-item')
+ expect(inputs.at(3).props().value).toEqual(variable.step)
+ expect(inputs.at(3).props().className).toBe('variable-property--input-item')
+ expect(
+ component
+ .find('select')
+ .at(0)
+ .props().className
+ ).toBe('variable-property--select-item')
+ })
+
+ // bonus test here to make sure the seriesType invalid option warning appears
+ test('renders with errors, type matches', () => {
+ const variable = {
+ name: 'e',
+ step: '1.1',
+ type: 'random-sequence',
+ sizeMin: '1',
+ sizeMax: '10',
+ start: '10',
+ seriesType: 'invalid',
+ errors: {
+ sizeMin: true,
+ seriesType: true
+ }
+ }
+
+ const component = shallow()
+
+ const inputs = component.find('input')
+ // inputs should not indicate errors - random-sequence types are selects, not inputs
+ expect(inputs.at(0).props().value).toEqual(variable.sizeMin)
+ expect(inputs.at(0).props().className).toBe('variable-property--input-item has-error')
+ expect(inputs.at(1).props().value).toEqual(variable.sizeMax)
+ expect(inputs.at(1).props().className).toBe('variable-property--input-item')
+ expect(inputs.at(2).props().value).toEqual(variable.start)
+ expect(inputs.at(2).props().className).toBe('variable-property--input-item')
+ expect(inputs.at(3).props().value).toEqual(variable.step)
+ expect(inputs.at(3).props().className).toBe('variable-property--input-item')
+ expect(
+ component
+ .find('select')
+ .at(0)
+ .props().className
+ ).toBe('variable-property--select-item has-error')
+ expect(component.find('.invalid-value-warning').length).toBe(1)
+ })
+})
diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-util.test.js b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-util.test.js
new file mode 100644
index 0000000000..1c0eef8afc
--- /dev/null
+++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/variables/variable-util.test.js
@@ -0,0 +1,677 @@
+jest.mock('../../../../src/scripts/common/util/range-parsing')
+
+import {
+ STATIC_VALUE,
+ STATIC_LIST,
+ RANDOM_NUMBER,
+ RANDOM_LIST,
+ RANDOM_SEQUENCE,
+ PICK_ONE,
+ PICK_LIST
+} from '../../../../src/scripts/oboeditor/components/variables/constants'
+
+import { getParsedRange } from '../../../../src/scripts/common/util/range-parsing'
+
+import {
+ changeVariableToType,
+ validateVariableValue,
+ validateMultipleVariables,
+ rangesToIndividualValues,
+ individualValuesToRanges
+} from '../../../../src/scripts/oboeditor/components/variables/variable-util'
+
+// this is literally the same block as in the source file - may be a more elegant way of doing this?
+const DEFAULT_VALUES = {
+ value: '',
+ valueMin: '0',
+ valueMax: '0',
+ decimalPlacesMin: '0',
+ decimalPlacesMax: '0',
+ sizeMin: '1',
+ sizeMax: '1',
+ unique: false,
+ start: '0',
+ seriesType: '',
+ step: '0',
+ chooseMin: '0',
+ chooseMax: '0',
+ ordered: false
+}
+// we may want to make this some kind of shared constant somewhere, rather than the current approach?
+const TYPE_KEYS = {
+ [STATIC_VALUE]: ['name', 'type', 'value'],
+ [STATIC_LIST]: ['name', 'type', 'value'],
+ [PICK_ONE]: ['name', 'type', 'value'],
+ [RANDOM_NUMBER]: ['name', 'type', 'valueMin', 'valueMax', 'decimalPlacesMin', 'decimalPlacesMax'],
+ [RANDOM_LIST]: [
+ 'name',
+ 'type',
+ 'sizeMin',
+ 'sizeMax',
+ 'unique',
+ 'valueMin',
+ 'valueMax',
+ 'decimalPlacesMin',
+ 'decimalPlacesMax'
+ ],
+ [RANDOM_SEQUENCE]: ['name', 'type', 'sizeMin', 'sizeMax', 'start', 'seriesType', 'step'],
+ [PICK_LIST]: ['name', 'type', 'chooseMin', 'chooseMax', 'ordered']
+}
+
+describe('VariableUtil', () => {
+ beforeEach(() => {
+ jest.resetAllMocks()
+ // this function really just takes a string in the format of [#,#] and returns an object as below
+ getParsedRange.mockReturnValue({ min: 0, max: 1 })
+ })
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ test.each`
+ propertyName | propertyValue | expectedReturn
+ ${'name'} | ${''} | ${true}
+ ${'name'} | ${'!invalid'} | ${true}
+ ${'name'} | ${'invalid_420~'} | ${true}
+ ${'name'} | ${'1invalid'} | ${true}
+ ${'name'} | ${'valid_420'} | ${false}
+ ${'name'} | ${'_ALSO_420_VALID'} | ${false}
+ ${'decimalPlacesMin'} | ${''} | ${true}
+ ${'decimalPlacesMin'} | ${'string'} | ${true}
+ ${'decimalPlacesMin'} | ${'1.1'} | ${true}
+ ${'decimalPlacesMin'} | ${'1'} | ${false}
+ ${'decimalPlacesMin'} | ${'01'} | ${false}
+ ${'decimalPlacesMax'} | ${''} | ${true}
+ ${'decimalPlacesMax'} | ${'string'} | ${true}
+ ${'decimalPlacesMax'} | ${'1.1'} | ${true}
+ ${'decimalPlacesMax'} | ${'1'} | ${false}
+ ${'decimalPlacesMax'} | ${'01'} | ${false}
+ ${'sizeMin'} | ${''} | ${true}
+ ${'sizeMin'} | ${'string'} | ${true}
+ ${'sizeMin'} | ${'1.1'} | ${true}
+ ${'sizeMin'} | ${'1'} | ${false}
+ ${'sizeMin'} | ${'01'} | ${false}
+ ${'sizeMax'} | ${''} | ${true}
+ ${'sizeMax'} | ${'string'} | ${true}
+ ${'sizeMax'} | ${'1.1'} | ${true}
+ ${'sizeMax'} | ${'1'} | ${false}
+ ${'sizeMax'} | ${'01'} | ${false}
+ ${'chooseMin'} | ${''} | ${true}
+ ${'chooseMin'} | ${'string'} | ${true}
+ ${'chooseMin'} | ${'1.1'} | ${true}
+ ${'chooseMin'} | ${'1'} | ${false}
+ ${'chooseMin'} | ${'01'} | ${false}
+ ${'chooseMax'} | ${''} | ${true}
+ ${'chooseMax'} | ${'string'} | ${true}
+ ${'chooseMax'} | ${'1.1'} | ${true}
+ ${'chooseMax'} | ${'1'} | ${false}
+ ${'chooseMax'} | ${'01'} | ${false}
+ ${'valueMin'} | ${''} | ${true}
+ ${'valueMin'} | ${'string'} | ${true}
+ ${'valueMin'} | ${'1'} | ${false}
+ ${'valueMin'} | ${'1.1'} | ${false}
+ ${'valueMax'} | ${''} | ${true}
+ ${'valueMax'} | ${'string'} | ${true}
+ ${'valueMax'} | ${'1'} | ${false}
+ ${'valueMax'} | ${'1.1'} | ${false}
+ ${'start'} | ${''} | ${true}
+ ${'start'} | ${'string'} | ${true}
+ ${'start'} | ${'1'} | ${false}
+ ${'start'} | ${'1.1'} | ${false}
+ ${'step'} | ${''} | ${true}
+ ${'step'} | ${'string'} | ${true}
+ ${'step'} | ${'1'} | ${false}
+ ${'step'} | ${'1.1'} | ${false}
+ ${'seriesType'} | ${''} | ${true}
+ ${'seriesType'} | ${'invalidOption'} | ${true}
+ ${'seriesType'} | ${'arithmetic'} | ${false}
+ ${'seriesType'} | ${'geometric'} | ${false}
+ ${'unidentifiedType'} | ${''} | ${false}
+ `(
+ "validateVariableValue returns $expectedReturn when $propertyName is '$propertyValue'",
+ ({ propertyName, propertyValue, expectedReturn }) => {
+ expect(validateVariableValue(propertyName, propertyValue)).toBe(expectedReturn)
+ }
+ )
+
+ test('validateMultipleVariables identifies all issues with multiple variables', async () => {
+ // this isn't ideal since it's calling an actual secondary function besides the one we're testing
+ // but unless it's possible to mock functions in a module while also testing that module, we're
+ // kind of stuck doing it this way
+ // three variables - one with no problems, one with two problems, one with one problem
+ const mockVariablesIn = [
+ // no problems
+ { name: 'var1', valueMin: '100', valueMax: '101' },
+ // two problems
+ { name: 'var2', valueMin: '', valueMax: '' },
+ // one problem
+ { name: 'var3', valueMin: '100', valueMax: '' }
+ ]
+ const variablesOut = validateMultipleVariables(mockVariablesIn)
+
+ // this is probably not ideal, but it'll do
+ expect(variablesOut).toEqual([
+ mockVariablesIn[0],
+ {
+ ...mockVariablesIn[1],
+ errors: {
+ valueMin: true,
+ valueMax: true
+ }
+ },
+ {
+ ...mockVariablesIn[2],
+ errors: {
+ valueMax: true
+ }
+ }
+ ])
+ })
+
+ const changeVariableAndCheckExpectationsWithType = (variableIn, type, expectErrors = false) => {
+ const variableOut = changeVariableToType(variableIn, type)
+
+ const expectedKeys = TYPE_KEYS[type]
+
+ expect(Object.keys(variableOut).length).toEqual(
+ expectErrors ? expectedKeys.length + 1 : expectedKeys.length
+ )
+ expectedKeys.forEach(expectedKey => {
+ if (expectedKey !== 'name' && expectedKey !== 'type') {
+ expect(variableOut[expectedKey]).toEqual(DEFAULT_VALUES[expectedKey])
+ }
+ })
+ if (!expectErrors) expect(variableOut.errors).toBeUndefined()
+
+ return variableOut
+ }
+ test('changeVariableToType manages variable type changes properly for all valid types', () => {
+ // this will also run validateVariableValue, which isn't ideal if we only want to
+ // test one function - and we also have to adjust our expectations based on real
+ // errors rather than mocked errors
+ let variableOut
+ let variableIn = {
+ name: 'mockvar',
+ type: 'does-not-matter',
+ someKey: '',
+ someOtherKey: ''
+ }
+ variableOut = changeVariableAndCheckExpectationsWithType(variableIn, STATIC_VALUE)
+ // make sure unnecessary props are stripped
+ expect(variableOut.someKey).toBeUndefined()
+ expect(variableOut.someOtherKey).toBeUndefined()
+
+ // pretend we're changing the variable frome one type to a compatible type
+ variableIn = { ...variableOut }
+ variableOut = changeVariableAndCheckExpectationsWithType(variableIn, STATIC_LIST)
+
+ // since in this case the variable types are compatible, there should not be any changes
+ expect(variableOut).toEqual(variableIn)
+
+ // same as the last one, but add something unnecessary
+ variableIn = {
+ ...variableOut,
+ surpriseNewKey: ''
+ }
+ variableOut = changeVariableAndCheckExpectationsWithType(variableIn, PICK_ONE)
+ expect(variableOut.surpriseNewKey).toBeUndefined()
+
+ // now change it to a new type
+ variableOut = changeVariableAndCheckExpectationsWithType(variableIn, RANDOM_NUMBER)
+ // we happen to know the previous type had this key that the new type does not, so
+ // this is a little magical
+ expect(variableOut.value).toBeUndefined()
+
+ variableIn = { ...variableOut }
+ variableOut = changeVariableAndCheckExpectationsWithType(variableIn, RANDOM_LIST)
+ // a little magical here as well - we happen to know that the previous type had all
+ // the same keys as the new type, but the new type also has two additional keys
+ expect(Object.keys(variableIn).length).toBeLessThan(Object.keys(variableOut).length)
+
+ variableIn = { ...variableOut }
+ // expect an error here because the default value for 'seriesType' is intentionally incorrect
+ variableOut = changeVariableAndCheckExpectationsWithType(variableIn, RANDOM_SEQUENCE, true)
+ expect(Object.keys(variableOut.errors).length).toBe(1)
+ expect(variableOut.errors).toEqual({ seriesType: true })
+
+ variableIn = { ...variableOut }
+ variableOut = changeVariableAndCheckExpectationsWithType(variableIn, PICK_LIST)
+ // more magic, but we happen to know here that the new type has totally different keys than the old
+ expect(variableOut.sizeMin).toBeUndefined()
+ expect(variableOut.sizeMax).toBeUndefined()
+ expect(variableOut.start).toBeUndefined()
+ expect(variableOut.seriesType).toBeUndefined()
+ expect(variableOut.step).toBeUndefined()
+ })
+
+ test('rangesToIndividualValues returns an empty array if given nothing', () => {
+ expect(rangesToIndividualValues()).toEqual([])
+ })
+
+ test('rangesToIndividualValues performs substitutes and returns variables - random list', () => {
+ let i = 1
+ const mockRandomListVar = () => ({
+ name: `mockvar${i++}`,
+ type: RANDOM_LIST,
+ size: '[0,0]',
+ decimalPlaces: '[0,0]',
+ value: '[0,0]',
+ unique: false
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockRandomListVar(),
+ {
+ ...mockRandomListVar(),
+ size: '[1,24]'
+ },
+ {
+ ...mockRandomListVar(),
+ decimalPlaces: '[1,2]'
+ }
+ ]
+
+ const variablesOut = rangesToIndividualValues(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+ expect(getParsedRange).toHaveBeenCalledTimes(variablesIn.length * 3)
+
+ // iterator to keep track of calls to getParsedRange
+ let k = 0
+ // this is magical since we happen to know what the expected output should be
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(getParsedRange.mock.calls[k++][0]).toEqual(variablesIn[j].size)
+ expect(getParsedRange.mock.calls[k++][0]).toEqual(variablesIn[j].decimalPlaces)
+ expect(getParsedRange.mock.calls[k++][0]).toEqual(variablesIn[j].value)
+
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: RANDOM_LIST,
+ sizeMin: 0,
+ sizeMax: 1,
+ decimalPlacesMin: 0,
+ decimalPlacesMax: 1,
+ valueMin: 0,
+ valueMax: 1,
+ unique: false
+ })
+ }
+ })
+
+ test('rangesToIndividualValues performs substitutes and returns variables - random sequence', () => {
+ let i = 1
+ const mockRandomSequenceVar = () => ({
+ name: `mockvar${i++}`,
+ type: RANDOM_SEQUENCE,
+ size: '[0,0]',
+ start: 0,
+ step: 0,
+ seriesType: 'seriesType'
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockRandomSequenceVar(),
+ {
+ ...mockRandomSequenceVar(),
+ size: '[1,24]'
+ }
+ ]
+
+ const variablesOut = rangesToIndividualValues(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+ expect(getParsedRange).toHaveBeenCalledTimes(variablesIn.length)
+
+ // this is magical since we happen to know what the expected output should be
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(getParsedRange.mock.calls[j][0]).toEqual(variablesIn[j].size)
+
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: RANDOM_SEQUENCE,
+ sizeMin: 0,
+ sizeMax: 1,
+ start: 0,
+ step: 0,
+ seriesType: 'seriesType'
+ })
+ }
+ })
+
+ test('rangesToIndividualValues performs substitutes and returns variables - random number', () => {
+ let i = 1
+ const mockRandomNumberVar = () => ({
+ name: `mockvar${i++}`,
+ type: RANDOM_NUMBER,
+ value: '[0,0]',
+ decimalPlaces: '[0,0]'
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockRandomNumberVar(),
+ {
+ ...mockRandomNumberVar(),
+ decimalPlaces: '[1,4]'
+ }
+ ]
+
+ const variablesOut = rangesToIndividualValues(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+ expect(getParsedRange).toHaveBeenCalledTimes(variablesIn.length * 2)
+
+ // iterator to keep track of calls to getParsedRange
+ let k = 0
+ // this is magical since we happen to know what the expected output should be
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(getParsedRange.mock.calls[k++][0]).toEqual(variablesIn[j].value)
+ expect(getParsedRange.mock.calls[k++][0]).toEqual(variablesIn[j].decimalPlaces)
+
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: RANDOM_NUMBER,
+ valueMin: 0,
+ valueMax: 1,
+ decimalPlacesMin: 0,
+ decimalPlacesMax: 1
+ })
+ }
+ })
+
+ test('rangesToIndividualValues performs substitutes and returns variables - pick list', () => {
+ let i = 1
+ const mockPickListVar = () => ({
+ name: `mockvar${i++}`,
+ type: PICK_LIST,
+ choose: '[0,0]',
+ value: 'value',
+ ordered: false
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockPickListVar(),
+ {
+ ...mockPickListVar(),
+ choose: '[1,4]'
+ }
+ ]
+
+ const variablesOut = rangesToIndividualValues(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+ expect(getParsedRange).toHaveBeenCalledTimes(variablesIn.length)
+
+ // this is magical since we happen to know what the expected output should be
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(getParsedRange.mock.calls[j][0]).toEqual(variablesIn[j].choose)
+
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: PICK_LIST,
+ chooseMin: 0,
+ chooseMax: 1,
+ value: 'value',
+ ordered: false
+ })
+ }
+ })
+
+ test('rangesToIndividualValues performs substitutes and returns variables - pick one, static value, static list', () => {
+ let i = 1
+ const mockVar = type => ({
+ name: `mockvar${i++}`,
+ type: type,
+ value: 'value'
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockVar(STATIC_VALUE),
+ {
+ ...mockVar(PICK_ONE),
+ value: 'pick_one_value'
+ },
+ {
+ ...mockVar(STATIC_LIST),
+ value: 'static_list_value'
+ }
+ ]
+
+ const variablesOut = rangesToIndividualValues(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+ expect(getParsedRange).not.toHaveBeenCalled()
+
+ // this is magical since we happen to know what the expected output should be
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: variablesIn[j].type,
+ value: variablesIn[j].value
+ })
+ }
+ })
+
+ test('rangesToIndividualValues throws an error when finding an unsupported variable type', () => {
+ const variablesIn = [
+ {
+ name: 'mockvar',
+ type: 'mock-variable-type'
+ }
+ ]
+ expect(() => {
+ rangesToIndividualValues(variablesIn)
+ }).toThrow('Unexpected type!')
+ })
+
+ test('individualValuesToRanges returns an empty array if given nothing', () => {
+ expect(individualValuesToRanges()).toEqual([])
+ })
+
+ test('individualValuesToRanges performs substitutes and returns variables - random list', () => {
+ let i = 1
+ const mockRandomListVar = () => ({
+ name: `mockvar${i++}`,
+ type: RANDOM_LIST,
+ sizeMin: 0,
+ sizeMax: 1,
+ decimalPlacesMin: 0,
+ decimalPlacesMax: 1,
+ valueMin: 0,
+ valueMax: 1,
+ unique: false
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockRandomListVar(),
+ {
+ ...mockRandomListVar(),
+ sizeMin: 1,
+ sizeMax: 24
+ },
+ {
+ ...mockRandomListVar(),
+ decimalPlacesMin: 1,
+ decimalPlacesMax: 2
+ }
+ ]
+
+ const variablesOut = individualValuesToRanges(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+
+ // this is magical since we happen to know what the expected output should be
+ // it's also kind of doing the same exact thing the function we're testing is doing, but
+ // this is also the best way to check that output is correct, so it'll have to do for now
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: RANDOM_LIST,
+ size: `[${variablesIn[j].sizeMin},${variablesIn[j].sizeMax}]`,
+ decimalPlaces: `[${variablesIn[j].decimalPlacesMin},${variablesIn[j].decimalPlacesMax}]`,
+ value: `[${variablesIn[j].valueMin},${variablesIn[j].valueMax}]`,
+ unique: false
+ })
+ }
+ })
+
+ test('individualValuesToRanges performs substitutes and returns variables - random sequence', () => {
+ let i = 1
+ const mockRandomSequenceVar = () => ({
+ name: `mockvar${i++}`,
+ type: RANDOM_SEQUENCE,
+ sizeMin: 0,
+ sizeMax: 1,
+ start: 0,
+ step: 0,
+ seriesType: 'seriesType'
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockRandomSequenceVar(),
+ {
+ ...mockRandomSequenceVar(),
+ sizeMin: 1,
+ sizeMax: 24
+ }
+ ]
+
+ const variablesOut = individualValuesToRanges(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+
+ // this is magical since we happen to know what the expected output should be
+ // it's also kind of doing the same exact thing the function we're testing is doing, but
+ // this is also the best way to check that output is correct, so it'll have to do for now
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: RANDOM_SEQUENCE,
+ size: `[${variablesIn[j].sizeMin},${variablesIn[j].sizeMax}]`,
+ start: 0,
+ step: 0,
+ seriesType: 'seriesType'
+ })
+ }
+ })
+
+ test('individualValuesToRanges performs substitutes and returns variables - random number', () => {
+ let i = 1
+ const mockRandomNumberVar = () => ({
+ name: `mockvar${i++}`,
+ type: RANDOM_NUMBER,
+ valueMin: 0,
+ valueMax: 1,
+ decimalPlacesMin: 0,
+ decimalPlacesMax: 1
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockRandomNumberVar(),
+ {
+ ...mockRandomNumberVar(),
+ decimalPlacesMin: 1,
+ decimalPlacesMax: 4
+ }
+ ]
+
+ const variablesOut = individualValuesToRanges(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+
+ // this is magical since we happen to know what the expected output should be
+ // it's also kind of doing the same exact thing the function we're testing is doing, but
+ // this is also the best way to check that output is correct, so it'll have to do for now
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: RANDOM_NUMBER,
+ value: `[${variablesIn[j].valueMin},${variablesIn[j].valueMax}]`,
+ decimalPlaces: `[${variablesIn[j].decimalPlacesMin},${variablesIn[j].decimalPlacesMax}]`
+ })
+ }
+ })
+
+ test('individualValuesToRanges performs substitutes and returns variables - pick list', () => {
+ let i = 1
+ const mockPickListVar = () => ({
+ name: `mockvar${i++}`,
+ type: PICK_LIST,
+ chooseMin: 0,
+ chooseMax: 1,
+ value: 'value',
+ ordered: false
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockPickListVar(),
+ {
+ ...mockPickListVar(),
+ chooseMin: 1,
+ chooseMax: 4
+ }
+ ]
+
+ const variablesOut = individualValuesToRanges(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+
+ // this is magical since we happen to know what the expected output should be
+ // it's also kind of doing the same exact thing the function we're testing is doing, but
+ // this is also the best way to check that output is correct, so it'll have to do for now
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: PICK_LIST,
+ choose: `[${variablesIn[j].chooseMin},${variablesIn[j].chooseMax}]`,
+ value: 'value',
+ ordered: false
+ })
+ }
+ })
+
+ test('individualValuesToRanges performs substitutes and returns variables - pick one, static value, static list', () => {
+ let i = 1
+ const mockVar = type => ({
+ name: `mockvar${i++}`,
+ type: type,
+ value: 'value'
+ })
+
+ // parses random list variables
+ const variablesIn = [
+ mockVar(STATIC_VALUE),
+ {
+ ...mockVar(PICK_ONE),
+ value: 'pick_one_value'
+ },
+ {
+ ...mockVar(STATIC_LIST),
+ value: 'static_list_value'
+ }
+ ]
+
+ const variablesOut = individualValuesToRanges(variablesIn)
+ expect(variablesOut.length).toEqual(variablesIn.length)
+
+ // this is magical since we happen to know what the expected output should be
+ for (let j = 0; j < variablesOut.length; j++) {
+ expect(variablesOut[j]).toEqual({
+ name: `mockvar${j + 1}`,
+ type: variablesIn[j].type,
+ value: variablesIn[j].value
+ })
+ }
+ })
+
+ test('individualValuesToRanges throws an error when finding an unsupported variable type', () => {
+ const variablesIn = [
+ {
+ name: 'mockvar',
+ type: 'mock-variable-type'
+ }
+ ]
+ expect(() => {
+ individualValuesToRanges(variablesIn)
+ }).toThrow('Unexpected type!')
+ })
+})
diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/visual-editor.test.js b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/visual-editor.test.js
index 7bd38f546e..c400be94a2 100644
--- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/visual-editor.test.js
+++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/visual-editor.test.js
@@ -176,13 +176,49 @@ describe('VisualEditor', () => {
component.instance().decorate([
{
type: BREAK_NODE,
- children: [{ text: '' }]
+ children: [{ text: '{{variable}}' }]
},
[0]
])
).toMatchSnapshot()
})
+ test('VisualEditor attaches decorators when necessary', () => {
+ const component = mount()
+ // this feels a bit contrived, but it'll do
+ // this feature may be temporary, anyway
+ const decorated = component.instance().decorate([
+ {
+ type: 'node-type',
+ text: '{{variable}}'
+ },
+ [0]
+ ])[0]
+ expect(decorated.highlight).toBe(true)
+ expect(decorated.variable).toBe('variable')
+ })
+
+ test('renderLeaf returns highlighted spans when necessary', () => {
+ const component = mount()
+ // this feels a bit contrived, but it'll do
+ // this feature may be temporary, anyway
+ const renderedLeaf = component.instance().renderLeaf({
+ attributes: { 'data-slate-leaf': true },
+ leaf: {
+ text: '{{variable}}',
+ highlight: true,
+ variable: 'variable'
+ },
+ text: { text: '{{variable}}' }
+ })
+ expect(renderedLeaf.props).toEqual({
+ className: 'todo--highlight',
+ 'data-var': 'variable',
+ 'data-slate-leaf': true,
+ children: undefined
+ })
+ })
+
test('VisualEditor component with Elements', () => {
jest.spyOn(Common.Registry, 'getItemForType').mockReturnValue({
plugins: {
diff --git a/packages/app/obojobo-document-engine/src/scripts/common/chunk/text-chunk/text-group-el.js b/packages/app/obojobo-document-engine/src/scripts/common/chunk/text-chunk/text-group-el.js
index a6b80636d6..576c6afcb5 100644
--- a/packages/app/obojobo-document-engine/src/scripts/common/chunk/text-chunk/text-group-el.js
+++ b/packages/app/obojobo-document-engine/src/scripts/common/chunk/text-chunk/text-group-el.js
@@ -41,6 +41,7 @@ const getText = props => {
// Loop through the text, replacing every substitution in substitutions
substitutions.forEach(sub => {
text.replaceText(sub.index, sub.index + sub.length, sub.replacementText)
+ text.styleText('color', sub.index, sub.index + sub.replacementText.length, { text: 'red' })
})
return text
diff --git a/packages/app/obojobo-document-engine/src/scripts/common/models/obo-model.js b/packages/app/obojobo-document-engine/src/scripts/common/models/obo-model.js
index 7be84809eb..448c8a25d1 100644
--- a/packages/app/obojobo-document-engine/src/scripts/common/models/obo-model.js
+++ b/packages/app/obojobo-document-engine/src/scripts/common/models/obo-model.js
@@ -97,6 +97,7 @@ class OboModel extends Backbone.Model {
this.parent = null
this.children = new OboModelCollection()
this.triggers = attrs.content && attrs.content.triggers ? attrs.content.triggers : []
+ this.variables = attrs.content && attrs.content.variables ? attrs.content.variables : []
this.objectives = attrs.content && attrs.content.objectives ? attrs.content.objectives : []
this.title = attrs.content && attrs.content.title ? attrs.content.title : null
diff --git a/packages/app/obojobo-document-engine/src/scripts/common/util/feature-flags.js b/packages/app/obojobo-document-engine/src/scripts/common/util/feature-flags.js
new file mode 100644
index 0000000000..899cfb0a02
--- /dev/null
+++ b/packages/app/obojobo-document-engine/src/scripts/common/util/feature-flags.js
@@ -0,0 +1,137 @@
+/*
+Defines an API for users and the codebase to get, set and clear feature flags.
+It is intentionally kept very basic - Flags are key value pairs where both key and value are strings
+only.
+Feature flags are kept in localStorage as encoded JSON. If at any time the JSON can't be parsed and
+there's an error then all set flags are deleted to return to a clean slate, so that there are no
+issues with bad feature flag settings stopping Obojobo from running.
+It is expected that this API will be exposed to the user via window.
+
+Usage examples:
+
+Expose this singleton to the user:
+ window.obojobo.flags = FeatureFlags
+
+How a user would interact with this in a javascript console (examples):
+ obojobo.flags.set('experimental.darkMode', obojobo.flags.ENABLED)
+ > true
+
+ obojobo.flags.list()
+ > { "experimental.darkMode": "enabled" }
+
+ obojobo.flags.clear('experimental.darkMode')
+ > true
+
+ obojobo.flags.list()
+ > {}
+
+How to incorporate into your code:
+ const FEATURE_FLAG_DARK_MODE = 'experimental.darkMode'
+ // ...
+ if(FeatureFlags.is(FEATURE_FLAG_DARK_MODE, FeatureFlags.ENABLED)) { ... }
+*/
+
+const LOCAL_STORAGE_FLAGS_KEY = 'obojobo:flags'
+
+const writeFlagsToLocalStorage = flags => {
+ try {
+ const string = JSON.stringify(flags)
+ window.localStorage[LOCAL_STORAGE_FLAGS_KEY] = string
+
+ return true
+ } catch (e) {
+ //eslint-disable-next-line no-console
+ console.error('Unable to save feature flags: ' + e)
+ delete window.localStorage[LOCAL_STORAGE_FLAGS_KEY]
+
+ return false
+ }
+}
+
+const getFlagsFromLocalStorage = () => {
+ try {
+ const localStorageFlags = window.localStorage[LOCAL_STORAGE_FLAGS_KEY]
+
+ if (typeof localStorageFlags !== 'undefined') {
+ return JSON.parse(localStorageFlags)
+ }
+ } catch (e) {
+ //eslint-disable-next-line no-console
+ console.error('Unable to parse feature flags: ' + e)
+ delete window.localStorage[LOCAL_STORAGE_FLAGS_KEY]
+ }
+
+ return {}
+}
+
+let flags = {}
+
+class FeatureFlags {
+ constructor() {
+ flags = getFlagsFromLocalStorage()
+ }
+
+ // Sets the flag flagName to the value flagValue.
+ // flagValue must be a string, if not it will be converted to a string
+ // Returns true if the value was written, false otherwise
+ set(flagName, flagValue) {
+ const newFlags = { ...flags, [flagName]: '' + flagValue }
+
+ if (writeFlagsToLocalStorage(newFlags)) {
+ flags = newFlags
+ return true
+ }
+
+ flags = {}
+ return false
+ }
+
+ // Returns the value of flagName, or null if it doesn't exist
+ get(flagName) {
+ const value = flags[flagName]
+
+ if (typeof value === 'undefined') {
+ return null
+ }
+
+ return value
+ }
+
+ // Returns true if the value of flagName is value
+ is(flagName, value) {
+ return this.get(flagName) === '' + value
+ }
+
+ // Returns a copy of the value of all flags in memory
+ list() {
+ return { ...flags }
+ }
+
+ // Clears the value of flagName, returning true if successful
+ clear(flagName) {
+ const newFlags = { ...flags }
+ delete newFlags[flagName]
+
+ if (writeFlagsToLocalStorage(newFlags)) {
+ flags = newFlags
+ return true
+ }
+
+ flags = {}
+ return false
+ }
+
+ // Clears all flags, returning true if successful
+ clearAll() {
+ flags = {}
+ delete window.localStorage[LOCAL_STORAGE_FLAGS_KEY]
+
+ return true
+ }
+}
+
+const featureFlags = new FeatureFlags()
+
+featureFlags.ENABLED = 'enabled'
+
+export default featureFlags
diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/app.js b/packages/app/obojobo-document-engine/src/scripts/oboeditor/app.js
index dc58c33944..f2db2713e7 100644
--- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/app.js
+++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/app.js
@@ -1,6 +1,6 @@
import Common from 'obojobo-document-engine/src/scripts/common'
import Editor from './index'
-
+import FeatureFlags from 'obojobo-document-engine/src/scripts/common/util/feature-flags'
import React from 'react'
import ReactDOM from 'react-dom'
@@ -28,6 +28,11 @@ if (ie) {
window.onblur = onBlur
}
+// Expose an obojobo object:
+window.obojobo = {
+ flags: FeatureFlags
+}
+
window.__oboEditorRender = (settings = {}) => {
ReactDOM.render(
diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/editor-app.scss b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/editor-app.scss
index 3ffca88228..7cb8bc683a 100644
--- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/editor-app.scss
+++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/editor-app.scss
@@ -21,3 +21,9 @@
font-family: $font-monospace;
}
}
+
+.todo--highlight {
+ background: #f3d1e5;
+ border-radius: 0.05em;
+ color: #cc2387;
+}
diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/navigation/more-info-box.js b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/navigation/more-info-box.js
index b55fd8b0db..a05374acfa 100644
--- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/navigation/more-info-box.js
+++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/navigation/more-info-box.js
@@ -6,6 +6,8 @@ import React from 'react'
import MoreInfoIcon from '../../assets/more-info-icon'
import TriggerListModal from '../triggers/trigger-list-modal'
+import VariableListModal from '../variables/variable-list-modal'
+import FeatureFlags from '../../../common/util/feature-flags'
import ObjectiveListModal from '../objectives/objective-list-modal'
import ObjectiveListView from '../objectives/objective-list-view'
import objectivesContext from '../objectives/objective-context'
@@ -14,6 +16,8 @@ const { Button, Switch } = Common.components
const { TabTrap } = Common.components.modal
const { ModalUtil } = Common.util
+const FEATURE_FLAG_EXPERIMENTAL_VARIABLES = 'experimental.variables'
+
// convenience function to reduce function creation in render
const stopPropagation = event => event.stopPropagation()
@@ -50,6 +54,7 @@ class MoreInfoBox extends React.Component {
this.showObjectiveModal = this.showObjectiveModal.bind(this)
this.showTriggersModal = this.showTriggersModal.bind(this)
+ this.showVariablesModal = this.showVariablesModal.bind(this)
this.closeModal = this.closeModal.bind(this)
this.closeObjectiveModal = this.closeObjectiveModal.bind(this)
@@ -207,18 +212,33 @@ class MoreInfoBox extends React.Component {
this.setState({ modalOpen: true })
}
+ showVariablesModal() {
+ // Prevent info box from closing when modal is opened
+ document.removeEventListener('mousedown', this.handleClick, false)
+ ModalUtil.show()
+ this.setState({ modalOpen: true })
+ }
+
// TriggerListModal.onClose is called w/ no arguments when canceled
// TriggerListModal.onClose is called w/ triggers when save+closed
+
closeModal(modalState) {
ModalUtil.hide()
if (!modalState) return // do not save changes
- this.setState(prevState => ({
- content: { ...prevState.content, triggers: modalState.triggers },
- needsUpdate: true,
- modalOpen: false
- }))
+ this.setState(
+ prevState => {
+ return {
+ content: { ...prevState.content, ...modalState },
+ needsUpdate: true,
+ modalOpen: false
+ }
+ },
+ () => {
+ this.onSave()
+ }
+ )
}
renderItem(item) {
@@ -288,8 +308,7 @@ class MoreInfoBox extends React.Component {
}
}
renderInfoBox() {
- const triggers = this.state.content.triggers
- const objectives = this.state.content.objectives
+ const { triggers, objectives, variables } = this.state.content
return (