diff --git a/dist/form-host.dev.js b/dist/form-host.dev.js index 323f681..9771974 100644 --- a/dist/form-host.dev.js +++ b/dist/form-host.dev.js @@ -548,7 +548,7 @@ eval("const { useFakeTimers } = __webpack_require__(/*! sinon/lib/sinon/util/fak \**************************************/ /***/ ((module, __unused_webpack_exports, __webpack_require__) => { -eval("const _ = __webpack_require__(/*! lodash */ \"./node_modules/lodash/lodash.js\");\n\nconst getForm = () => $('form');\nconst getValidationErrors = () => getForm()\n .find('.invalid-required:not(.disabled), .invalid-constraint:not(.disabled), .invalid-relevant:not(.disabled)')\n .children('span.active:not(.question-label)')\n .filter(function() {\n return $(this).css('display') === 'block';\n });\n\nconst getSiblingElement = ( element, selector = '*' ) =>{\n let found;\n let current = element.parentElement.firstElementChild;\n\n while ( current && !found ) {\n if ( current !== element && current.matches( selector ) ) {\n found = current;\n }\n current = current.nextElementSibling;\n }\n\n return found;\n};\n\n// Copied from https://github.com/enketo/enketo-core/blob/master/src/js/page.js\nconst getPages = () => {\n const form = getForm()[0];\n if(!form.classList.contains('pages')) {\n // This is not a multipage form\n return [form];\n }\n\n const allPages = [...getForm()[0].querySelectorAll( '[role=\"page\"]' )];\n return allPages.filter( el => {\n return !el.closest( '.disabled' ) &&\n ( el.matches( '.question' ) || el.querySelector( '.question:not(.disabled)' ) ||\n // or-repeat-info is only considered a page by itself if it has no sibling repeats\n // When there are siblings repeats, we use CSS trickery to show the + button underneath the last\n // repeat.\n ( el.matches( '.or-repeat-info' ) && !getSiblingElement( el, '.or-repeat' ) ) );\n } );\n};\n\nconst getCurrentPage = () => {\n const pages = getPages();\n return pages.find( page => page.classList.contains( 'current' ) ) || pages[pages.length - 1];\n};\n\nclass FormFiller {\n constructor(options) {\n this.options = _.defaults(options, {\n verbose: true,\n });\n this.log = (...args) => this.options.verbose && console.log('FormFiller', ...args);\n }\n\n /**\n * An object describing the result of filling a form.\n * @typedef {Object} FillResult\n * @property {FillError[]} errors A list of errors\n * @property {string} section The page number on which the errors occurred\n * @property {Object} report The report object which resulted from submitting the filled report. Undefined if an error blocks form submission.\n * @property {Object[]} additionalDocs An array of database documents which are created in addition to the report.\n */\n\n /**\n * An object describing an error which has occurred while filling a form.\n * @typedef {Object} FillError\n * @property {string} type A classification of the error [ 'validation', 'general', 'page' ]\n * @property {string} msg Description of the error\n */\n\n async fillForm(multiPageAnswer) {\n const { isComplete, errors } = await fillForm(this, multiPageAnswer);\n return { isComplete, errors };\n }\n\n // Modified from enketo-core/src/js/Form.js validateContent\n async getVisibleValidationErrors() {\n const validationErrors = getValidationErrors();\n\n return Array.from(validationErrors)\n .map(span => ({\n type: 'validation',\n question: span.parentElement.innerText,\n msg: span.innerText,\n }));\n }\n}\n\nconst fillForm = async (self, multiPageAnswer) => {\n self.log(`Filling form in ${multiPageAnswer.length} pages.`);\n const results = [];\n for (const pageIndex in multiPageAnswer) {\n const pageAnswer = multiPageAnswer[pageIndex];\n const result = await fillPage(self, pageAnswer);\n results.push(result);\n\n if (result.errors.length > 0) {\n return {\n errors: result.errors,\n section: `page-${pageIndex}`,\n answers: pageAnswer,\n };\n }\n }\n\n let errors;\n let isComplete;\n let pageHasAdvanced;\n // attempt to submit all the way to the end (replacement for validateAll)\n do {\n pageHasAdvanced = await nextPage();\n errors = await self.getVisibleValidationErrors();\n\n const pages = getPages();\n isComplete = pages.indexOf(getCurrentPage()) === pages.length - 1;\n } while (pageHasAdvanced && !isComplete && !errors.length);\n const incompleteError = isComplete ? [] : [{ type: 'general', msg: 'Form is incomplete' }];\n\n return {\n isComplete,\n errors: [...incompleteError, ...errors],\n };\n};\n\nconst fillPage = async (self, pageAnswer) => {\n self.log(`Answering ${pageAnswer.length} questions.`);\n\n const answeredQuestions = new Set();\n for (let i = 0; i < pageAnswer.length; i++) {\n const answer = pageAnswer[i];\n const $questions = getVisibleQuestions();\n if ($questions.length <= i) {\n return {\n errors: [{\n type: 'page',\n answers: pageAnswer,\n section: `answer-${i}`,\n msg: `Attempted to fill ${pageAnswer.length} questions, but only ${$questions.length} are visible.`,\n }],\n };\n }\n\n const nextUnansweredQuestion = Array.from($questions).find(question => !answeredQuestions.has(question));\n answeredQuestions.add(nextUnansweredQuestion);\n fillQuestion(nextUnansweredQuestion, answer);\n }\n\n const allPagesSuccessful = await nextPage();\n const validationErrors = await self.getVisibleValidationErrors();\n const advanceFailure = allPagesSuccessful || validationErrors.length ? [] : [{\n type: 'general',\n msg: 'Failed to advance to next page',\n }];\n\n return {\n errors: [...advanceFailure, ...validationErrors],\n };\n};\n\nconst fillQuestion = (question, answer) => {\n if(answer === null || answer === undefined) {\n return;\n }\n\n const $question = $(question);\n const allInputs = $question.find('input:not([type=\"hidden\"]),textarea,button,select');\n const firstInput = Array.from(allInputs)[0];\n\n if (!firstInput) {\n throw 'No input field found within question';\n }\n\n if (firstInput.localName === 'textarea') {\n return allInputs.val(answer).trigger('change');\n }\n\n switch (firstInput.type) {\n case 'button':\n // select_one appearance:minimal\n if (firstInput.className.includes('dropdown-toggle')) {\n $question.find(`input[value=\"${answer}\"]:not([checked=\"checked\"])`).click();\n }\n\n // repeate section\n else {\n\n if (!Number.isInteger(answer)) {\n throw `Failed to answer question which is a \"+\" for repeat section. This question expects an answer which is an integer - representing how many times to click the +. \"${answer}\"`;\n }\n\n for (let i = 0; i < answer; ++i) {\n allInputs.click();\n }\n }\n break;\n case 'radio':\n $question.find(`input[value=\"${answer}\"]`).click();\n break;\n case 'date':\n case 'tel':\n case 'number':\n allInputs.val(answer).trigger('change');\n break;\n case 'text':\n if (allInputs.eq(0).parents('.datetimepicker').length) {\n const [date, time] = answer.split(' ', 2);\n if (!time) {\n throw new Error('Elements of type datetime expect input in format: \"2022-12-31 13:21\"');\n }\n\n allInputs.eq(0).datepicker('setDate', date);\n allInputs.eq(1).val(time).trigger('change');\n } else if (allInputs.eq(0).parents('.timepicker').length) {\n allInputs.eq(0).timepicker('setTime', answer);\n } else if (allInputs.parent().hasClass('date')) {\n allInputs.first().datepicker('setDate', answer);\n } else {\n allInputs.val(answer).trigger('change');\n }\n break;\n case 'checkbox': {\n /*\n There are two accepted formats for multi-select checkboxes\n Option 1 - A set of comma-delimited boolean strings representing the state of the boxes. eg. \"true,false,true\" checks the first and third box\n Option 2 - A set of comma-delimited values to be checked. eg. \"heart_condition,none\" checks the two boxes with corresponding values\n */\n const answerArray = Array.isArray(answer) ? answer.map(answer => answer.toString()) : answer.split(',');\n const isNonBooleanString = str => !str || !['true', 'false'].includes(str.toLowerCase());\n const answerContainsSpecificValues = answerArray.some(isNonBooleanString);\n\n // [value != \"\"] is necessary because blank lines in `choices` table of xlsx can cause empty unrendered input\n const options = $question.find('input[value!=\"\"]');\n\n if (!answerContainsSpecificValues) {\n answerArray.forEach((val, index) => {\n const propValue = val === true || val.toLowerCase() === 'true' ? 'checked' : '';\n $(options[index]).prop('checked', propValue).trigger('change');\n });\n } else {\n options.prop('checked', '');\n answerArray.forEach(val => $question.find(`input[value=\"${val}\"]`).prop('checked', 'checked').trigger('change'));\n }\n break;\n }\n case 'select-one':\n allInputs.val(answer).trigger('change');\n break;\n default:\n throw `Unhandled input type ${firstInput.type}`;\n }\n};\n\nconst getVisibleQuestions = () => {\n const currentPage = $(getCurrentPage());\n \n if (!currentPage) {\n throw Error('Form has no active pages');\n }\n\n if (currentPage.hasClass('question')) {\n return currentPage;\n }\n\n const findQuestionsInSection = section => {\n const inquisitiveChildren = Array.from($(section)\n .children(`\n section:not(.disabled,.or-appearance-hidden,.or-appearance-android-app-launcher),\n fieldset:not(.disabled,.note,.or-appearance-hidden,.or-appearance-label,#or-calculated-items),\n label:not(.disabled,.readonly,.or-appearance-hidden),\n div.or-repeat-info:not(.disabled,.or-appearance-hidden):not([data-repeat-count]),\n i,\n b\n `));\n\n const result = [];\n for (const child of inquisitiveChildren) {\n const questions = ['section', 'i', 'b'].includes(child.localName) ? findQuestionsInSection(child) : [child];\n result.push(...questions);\n }\n\n return result;\n };\n\n return findQuestionsInSection(currentPage);\n};\n\nconst nextPage = async () => {\n const currentPageIndex = getPages().indexOf(getCurrentPage());\n const nextButton = $('button.next-page');\n if(nextButton.is(':hidden')) {\n return !getValidationErrors().length;\n }\n\n return new Promise(resolve => {\n const observer = new MutationObserver(() => {\n if(getPages().indexOf(getCurrentPage()) > currentPageIndex) {\n observer.disconnect();\n return resolve(true);\n }\n if(getValidationErrors().length) {\n observer.disconnect();\n return resolve(false);\n }\n });\n\n observer.observe(getForm().get(0), {\n childList: true,\n subtree: true,\n attributeFilter: ['class', 'display'],\n });\n nextButton.click();\n });\n};\n\nmodule.exports = FormFiller;\n\n\n//# sourceURL=webpack://cht-conf-test-harness/./src/form-host/form-filler.js?"); +eval("const _ = __webpack_require__(/*! lodash */ \"./node_modules/lodash/lodash.js\");\n\nconst getForm = () => $('form');\nconst getValidationErrors = () => getForm()\n .find('.invalid-required:not(.disabled), .invalid-constraint:not(.disabled), .invalid-relevant:not(.disabled)')\n .children('span.active:not(.question-label)')\n .filter(function() {\n return $(this).css('display') === 'block';\n });\n\nconst getSiblingElement = ( element, selector = '*' ) =>{\n let found;\n let current = element.parentElement.firstElementChild;\n\n while ( current && !found ) {\n if ( current !== element && current.matches( selector ) ) {\n found = current;\n }\n current = current.nextElementSibling;\n }\n\n return found;\n};\n\n// Copied from https://github.com/enketo/enketo-core/blob/master/src/js/page.js\nconst getPages = () => {\n const form = getForm()[0];\n if(!form.classList.contains('pages')) {\n // This is not a multipage form\n return [form];\n }\n\n const allPages = [...getForm()[0].querySelectorAll( '[role=\"page\"]' )];\n return allPages.filter( el => {\n return !el.closest( '.disabled' ) &&\n ( el.matches( '.question' ) || el.querySelector( '.question:not(.disabled)' ) ||\n // or-repeat-info is only considered a page by itself if it has no sibling repeats\n // When there are siblings repeats, we use CSS trickery to show the + button underneath the last\n // repeat.\n ( el.matches( '.or-repeat-info' ) && !getSiblingElement( el, '.or-repeat' ) ) );\n } );\n};\n\nconst getCurrentPage = () => {\n const pages = getPages();\n return pages.find( page => page.classList.contains( 'current' ) ) || pages[pages.length - 1];\n};\n\nclass FormFiller {\n constructor(options) {\n this.options = _.defaults(options, {\n verbose: true,\n });\n this.log = (...args) => this.options.verbose && console.log('FormFiller', ...args);\n }\n\n /**\n * An object describing the result of filling a form.\n * @typedef {Object} FillResult\n * @property {FillError[]} errors A list of errors\n * @property {string} section The page number on which the errors occurred\n * @property {Object} report The report object which resulted from submitting the filled report. Undefined if an error blocks form submission.\n * @property {Object[]} additionalDocs An array of database documents which are created in addition to the report.\n */\n\n /**\n * An object describing an error which has occurred while filling a form.\n * @typedef {Object} FillError\n * @property {string} type A classification of the error [ 'validation', 'general', 'page' ]\n * @property {string} msg Description of the error\n */\n\n async fillForm(multiPageAnswer) {\n const { isComplete, errors } = await fillForm(this, multiPageAnswer);\n return { isComplete, errors };\n }\n\n // Modified from enketo-core/src/js/Form.js validateContent\n async getVisibleValidationErrors() {\n const validationErrors = getValidationErrors();\n\n return Array.from(validationErrors)\n .map(span => ({\n type: 'validation',\n question: span.parentElement.innerText,\n msg: span.innerText,\n }));\n }\n}\n\nconst fillForm = async (self, multiPageAnswer) => {\n self.log(`Filling form in ${multiPageAnswer.length} pages.`);\n const results = [];\n for (const pageIndex in multiPageAnswer) {\n const pageAnswer = multiPageAnswer[pageIndex];\n const result = await fillPage(self, pageAnswer);\n results.push(result);\n\n if (result.errors.length > 0) {\n return {\n errors: result.errors,\n section: `page-${pageIndex}`,\n answers: pageAnswer,\n };\n }\n }\n\n let errors;\n let isComplete;\n let pageHasAdvanced;\n // attempt to submit all the way to the end (replacement for validateAll)\n do {\n pageHasAdvanced = await nextPage();\n errors = await self.getVisibleValidationErrors();\n\n const pages = getPages();\n isComplete = pages.indexOf(getCurrentPage()) === pages.length - 1;\n } while (pageHasAdvanced && !isComplete && !errors.length);\n const incompleteError = isComplete ? [] : [{ type: 'general', msg: 'Form is incomplete' }];\n\n return {\n isComplete,\n errors: [...incompleteError, ...errors],\n };\n};\n\nconst fillPage = async (self, pageAnswer) => {\n self.log(`Answering ${pageAnswer.length} questions.`);\n\n const answeredQuestions = new Set();\n for (let i = 0; i < pageAnswer.length; i++) {\n const answer = pageAnswer[i];\n const $questions = getVisibleQuestions();\n if ($questions.length <= i) {\n return {\n errors: [{\n type: 'page',\n answers: pageAnswer,\n section: `answer-${i}`,\n msg: `Attempted to fill ${pageAnswer.length} questions, but only ${$questions.length} are visible.`,\n }],\n };\n }\n\n const nextUnansweredQuestion = Array.from($questions).find(question => !answeredQuestions.has(question));\n answeredQuestions.add(nextUnansweredQuestion);\n fillQuestion(nextUnansweredQuestion, answer);\n }\n\n const allPagesSuccessful = await nextPage();\n const validationErrors = await self.getVisibleValidationErrors();\n const advanceFailure = allPagesSuccessful || validationErrors.length ? [] : [{\n type: 'general',\n msg: 'Failed to advance to next page',\n }];\n\n return {\n errors: [...advanceFailure, ...validationErrors],\n };\n};\n\nconst fillQuestion = (question, answer) => {\n if(answer === null || answer === undefined) {\n return;\n }\n\n const $question = $(question);\n const allInputs = $question.find('input:not([type=\"hidden\"]),textarea,button,select');\n const firstInput = Array.from(allInputs)[0];\n\n if (!firstInput) {\n throw 'No input field found within question';\n }\n\n if (firstInput.localName === 'textarea') {\n return allInputs.val(answer).trigger('change');\n }\n\n switch (firstInput.type) {\n case 'button':\n // select_one appearance:minimal\n if (firstInput.className.includes('dropdown-toggle')) {\n $question.find(`input[value=\"${answer}\"]:not([checked=\"checked\"])`).click();\n }\n\n // repeate section\n else {\n\n if (!Number.isInteger(answer)) {\n throw `Failed to answer question which is a \"+\" for repeat section. This question expects an answer which is an integer - representing how many times to click the +. \"${answer}\"`;\n }\n\n for (let i = 0; i < answer; ++i) {\n allInputs.click();\n }\n }\n break;\n case 'radio':\n $question.find(`input[value=\"${answer}\"]`).click();\n break;\n case 'date':\n case 'tel':\n case 'number':\n allInputs.val(answer).trigger('change');\n break;\n case 'text':\n if (allInputs.eq(0).parents('.datetimepicker').length) {\n const [date, time] = answer.split(' ', 2);\n if (!time) {\n throw new Error('Elements of type datetime expect input in format: \"2022-12-31 13:21\"');\n }\n\n allInputs.eq(0).datepicker('setDate', date);\n allInputs.eq(1).val(time).trigger('change');\n } else if (allInputs.eq(0).parents('.timepicker').length) {\n allInputs.eq(0).timepicker('setTime', answer);\n } else if (allInputs.parent().hasClass('date')) {\n allInputs.first().datepicker('setDate', answer);\n } else {\n allInputs.val(answer).trigger('change');\n }\n break;\n case 'checkbox': {\n /*\n There are two accepted formats for multi-select checkboxes\n Option 1 - A set of comma-delimited boolean strings representing the state of the boxes. eg. \"true,false,true\" checks the first and third box\n Option 2 - A set of comma-delimited values to be checked. eg. \"heart_condition,none\" checks the two boxes with corresponding values\n */\n const answerArray = Array.isArray(answer) ? answer.map(answer => answer.toString()) : answer.split(',');\n const isNonBooleanString = str => !str || !['true', 'false'].includes(str.toLowerCase());\n const answerContainsSpecificValues = answerArray.some(isNonBooleanString);\n\n // [value != \"\"] is necessary because blank lines in `choices` table of xlsx can cause empty unrendered input\n const options = $question.find('input[value!=\"\"]');\n\n if (!answerContainsSpecificValues) {\n answerArray.forEach((val, index) => {\n const propValue = val === true || val.toLowerCase() === 'true' ? 'checked' : '';\n $(options[index]).prop('checked', propValue).trigger('change');\n });\n } else {\n options.prop('checked', '');\n answerArray.forEach(val => $question.find(`input[value=\"${val}\"]`).prop('checked', 'checked').trigger('change'));\n }\n break;\n }\n case 'select-one':\n allInputs.val(answer).trigger('change');\n break;\n default:\n throw `Unhandled input type ${firstInput.type}`;\n }\n};\n\nconst getVisibleQuestions = () => {\n const currentPage = $(getCurrentPage());\n \n if (!currentPage) {\n throw Error('Form has no active pages');\n }\n\n if (currentPage.hasClass('question')) {\n return currentPage;\n }\n\n const findQuestionsInSection = section => {\n const inquisitiveChildren = Array.from($(section)\n .children(`\n section:not(.disabled,.or-appearance-hidden,.or-appearance-android-app-launcher),\n fieldset:not(.disabled,.note,.or-appearance-hidden,.or-appearance-label,#or-calculated-items),\n label:not(.disabled,.readonly,.or-appearance-hidden),\n div.or-repeat-info:not(.disabled,.or-appearance-hidden):not([data-repeat-count]),\n i,\n b\n `));\n\n const result = [];\n for (const child of inquisitiveChildren) {\n const questions = ['section', 'i', 'b'].includes(child.localName) ? findQuestionsInSection(child) : [child];\n result.push(...questions);\n }\n\n return result;\n };\n\n return findQuestionsInSection(currentPage);\n};\n\nconst nextPage = async () => {\n const currentPageIndex = getPages().indexOf(getCurrentPage());\n const nextButton = $('button.next-page');\n if(nextButton.is(':hidden')) {\n const submitButton = $('button.submit');\n if (!submitButton.is(':hidden')) {\n // revalidate constraints before checking errors\n submitButton.click();\n }\n \n return !getValidationErrors().length;\n }\n\n return new Promise(resolve => {\n const observer = new MutationObserver(() => {\n if(getPages().indexOf(getCurrentPage()) > currentPageIndex) {\n observer.disconnect();\n return resolve(true);\n }\n\n if(getValidationErrors().length) {\n observer.disconnect();\n return resolve(false);\n }\n });\n\n observer.observe(getForm().get(0), {\n childList: true,\n subtree: true,\n attributeFilter: ['class', 'display'],\n });\n nextButton.click();\n });\n};\n\nmodule.exports = FormFiller;\n\n\n//# sourceURL=webpack://cht-conf-test-harness/./src/form-host/form-filler.js?"); /***/ }), diff --git a/package-lock.json b/package-lock.json index 1d0301f..28ff81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cht-conf-test-harness", - "version": "5.0.1", + "version": "5.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cht-conf-test-harness", - "version": "5.0.1", + "version": "5.0.2", "dependencies": { "lodash": "^4.17.15", "luxon": "^1.27.0", diff --git a/package.json b/package.json index 2ba7677..e6e1170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cht-conf-test-harness", - "version": "5.0.1", + "version": "5.0.2", "description": "Test Framework for CHT Projects", "repository": { "type": "git", diff --git a/src/form-host/form-filler.js b/src/form-host/form-filler.js index acc8eb8..ec86c10 100644 --- a/src/form-host/form-filler.js +++ b/src/form-host/form-filler.js @@ -290,6 +290,12 @@ const nextPage = async () => { const currentPageIndex = getPages().indexOf(getCurrentPage()); const nextButton = $('button.next-page'); if(nextButton.is(':hidden')) { + const submitButton = $('button.submit'); + if (!submitButton.is(':hidden')) { + // revalidate constraints before checking errors + submitButton.click(); + } + return !getValidationErrors().length; } @@ -299,6 +305,7 @@ const nextPage = async () => { observer.disconnect(); return resolve(true); } + if(getValidationErrors().length) { observer.disconnect(); return resolve(false); diff --git a/test/app-forms.spec.js b/test/app-forms.spec.js index 4d5ff6f..5152a45 100644 --- a/test/app-forms.spec.js +++ b/test/app-forms.spec.js @@ -326,7 +326,7 @@ describe('forms that have caused bugs', () => { it('#234 - datetime field fails enketo validation', async () => { const result = await harness.fillForm('bug_234', ['2023-01-01 x']); expect(result.errors).to.not.be.empty; - expect(result.errors[1].msg).to.eq('enketo.constraint.required'); + expect(result.errors[0].msg).to.eq('enketo.constraint.required'); }); it('#249 - time field with correct input format', async () => { @@ -358,4 +358,14 @@ describe('forms that have caused bugs', () => { expect(result.errors).to.be.empty; expect(result.report.fields.fp_follow_up.display_is_muted).to.eq(actual); }); + + it('#269 - submit clears erroneous constraints before reporting errors', async () => { + const result = await harness.fillForm('bug_269_submit', ['1', '1']); + expect(result.errors).to.be.empty; + }); + + it('#269 - next clears erroneous constraints before reporting errors', async () => { + const result = await harness.fillForm('bug_269_next', ['1', '1'], ['answer']); + expect(result.errors).to.be.empty; + }); }); diff --git a/test/collateral/forms/app/bug_269_next.xlsx b/test/collateral/forms/app/bug_269_next.xlsx new file mode 100644 index 0000000..021aada Binary files /dev/null and b/test/collateral/forms/app/bug_269_next.xlsx differ diff --git a/test/collateral/forms/app/bug_269_next.xml b/test/collateral/forms/app/bug_269_next.xml new file mode 100644 index 0000000..41b7c7c --- /dev/null +++ b/test/collateral/forms/app/bug_269_next.xml @@ -0,0 +1,88 @@ + + + + U5 Assessment + + + + + + + + + + + + + user + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + 1 + + + + 2 + + + + + + + 0 + + + + 1 + + + + 2 + + + + + + + + + + + diff --git a/test/collateral/forms/app/bug_269_submit.xlsx b/test/collateral/forms/app/bug_269_submit.xlsx new file mode 100644 index 0000000..69e728f Binary files /dev/null and b/test/collateral/forms/app/bug_269_submit.xlsx differ diff --git a/test/collateral/forms/app/bug_269_submit.xml b/test/collateral/forms/app/bug_269_submit.xml new file mode 100644 index 0000000..d3c603c --- /dev/null +++ b/test/collateral/forms/app/bug_269_submit.xml @@ -0,0 +1,78 @@ + + + + U5 Assessment + + + + + + + + + + + + + user + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + 1 + + + + 2 + + + + + + + 0 + + + + 1 + + + + 2 + + + + +