Browse Source
feat: implement testing suite and configuration files for conditional form logic and visibility rules
Dev
feat: implement testing suite and configuration files for conditional form logic and visibility rules
Dev
9 changed files with 409 additions and 0 deletions
-
1api_questions.json
-
1api_questions_marital.json
-
237conditional-rules.js
-
BINpublic/assets/images/sections/09_identity_verification_documents.png
-
47test-all.js
-
29test-cond.js
-
32test-cond2.js
-
36test-cond3.js
-
26test-empty.js
1
api_questions.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
1
api_questions_marital.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,237 @@ |
|||
"use strict"; |
|||
Object.defineProperty(exports, "__esModule", { value: true }); |
|||
exports.canonicalRule = canonicalRule; |
|||
exports.isAnswerPresent = isAnswerPresent; |
|||
exports.matchesAudience = matchesAudience; |
|||
exports.ruleMatches = ruleMatches; |
|||
exports.isQuestionVisible = isQuestionVisible; |
|||
exports.isQuestionRequired = isQuestionRequired; |
|||
function canonicalRule(rule) { |
|||
if (!rule || typeof rule !== "object") { |
|||
return null; |
|||
} |
|||
// If wrapped in dependsOn (legacy)
|
|||
if (rule.dependsOn && !rule.parent_question_id) { |
|||
return { |
|||
dependsOn: rule.dependsOn, |
|||
}; |
|||
} |
|||
var operator = ["any_of", "all_of", "equals", "exists"].includes(rule.operator) |
|||
? rule.operator |
|||
: "any_of"; |
|||
var optionIds = []; |
|||
if (Array.isArray(rule.trigger_option_ids)) { |
|||
optionIds = rule.trigger_option_ids.map(String); |
|||
} |
|||
else if (rule.trigger_option_ids !== undefined && rule.trigger_option_ids !== null) { |
|||
optionIds = [String(rule.trigger_option_ids)]; |
|||
} |
|||
var result = { |
|||
parent_question_id: rule.parent_question_id || rule.parentQuestionId, |
|||
trigger_option_ids: optionIds, |
|||
operator: operator, |
|||
clear_answer_when_hidden: Boolean(rule.clear_answer_when_hidden), |
|||
}; |
|||
if (rule.audience && typeof rule.audience === "object") { |
|||
result.audience = rule.audience; |
|||
} |
|||
if (Array.isArray(rule.conditions)) { |
|||
result.conditions = rule.conditions |
|||
.map(canonicalRule) |
|||
.filter(function (c) { return c !== null; }); |
|||
result.conditions_operator = |
|||
rule.conditions_operator === "any_of" ? "any_of" : "all_of"; |
|||
result.root_operator = |
|||
rule.root_operator === "any_of" ? "any_of" : "all_of"; |
|||
} |
|||
return result; |
|||
} |
|||
function isAnswerPresent(answer) { |
|||
if (answer === undefined || answer === null) { |
|||
return false; |
|||
} |
|||
var val = typeof answer === "object" && "value" in answer ? answer.value : answer; |
|||
if (val === undefined || val === null) { |
|||
return false; |
|||
} |
|||
if (typeof val === "string") { |
|||
return val.trim().length > 0; |
|||
} |
|||
if (Array.isArray(val)) { |
|||
return val.length > 0; |
|||
} |
|||
if (typeof val === "object") { |
|||
return Object.keys(val).length > 0; |
|||
} |
|||
return true; |
|||
} |
|||
function getSelectedOptionTokens(answer) { |
|||
var tokens = new Set(); |
|||
if (!answer) |
|||
return tokens; |
|||
var rawOptionId = typeof answer === "object" && "option_id" in answer |
|||
? answer.option_id |
|||
: undefined; |
|||
var rawValue = typeof answer === "object" && "value" in answer ? answer.value : answer; |
|||
var addToken = function (item) { |
|||
if (item === undefined || item === null) |
|||
return; |
|||
var str = String(item).trim(); |
|||
if (!str) |
|||
return; |
|||
tokens.add(str.toLowerCase()); |
|||
// If it has a dot prefix like "sec.q.opt", also add the suffix "opt"
|
|||
var lastDot = str.lastIndexOf("."); |
|||
if (lastDot !== -1 && lastDot < str.length - 1) { |
|||
tokens.add(str.slice(lastDot + 1).toLowerCase()); |
|||
} |
|||
}; |
|||
if (Array.isArray(rawOptionId)) { |
|||
rawOptionId.forEach(addToken); |
|||
} |
|||
else if (rawOptionId !== undefined) { |
|||
addToken(rawOptionId); |
|||
} |
|||
if (Array.isArray(rawValue)) { |
|||
rawValue.forEach(addToken); |
|||
} |
|||
else if (rawValue !== undefined) { |
|||
addToken(rawValue); |
|||
} |
|||
return tokens; |
|||
} |
|||
function matchesAudience(audience, context) { |
|||
if (!audience || typeof audience !== "object") { |
|||
return true; |
|||
} |
|||
if (audience.genders && audience.genders.length > 0) { |
|||
if ((context === null || context === void 0 ? void 0 : context.gender) && !audience.genders.includes(context.gender)) { |
|||
return false; |
|||
} |
|||
} |
|||
if (audience.minAge !== undefined && (context === null || context === void 0 ? void 0 : context.age) !== undefined && context.age !== null) { |
|||
if (context.age < audience.minAge) { |
|||
return false; |
|||
} |
|||
} |
|||
if (audience.maxAge !== undefined && (context === null || context === void 0 ? void 0 : context.age) !== undefined && context.age !== null) { |
|||
if (context.age > audience.maxAge) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
function ruleMatches(rawRule, answers, context) { |
|||
var rule = canonicalRule(rawRule); |
|||
if (!rule) { |
|||
return true; |
|||
} |
|||
if (rule.audience && !matchesAudience(rule.audience, context)) { |
|||
return false; |
|||
} |
|||
// Handle legacy dependsOn
|
|||
if (rule.dependsOn && rule.dependsOn.key) { |
|||
var parentId_1 = rule.dependsOn.key; |
|||
var answer = answers[parentId_1]; |
|||
if (!isAnswerPresent(answer)) { |
|||
return false; |
|||
} |
|||
var expectedValues = (rule.dependsOn.values || []).map(function (v) { |
|||
return String(v).toLowerCase().trim(); |
|||
}); |
|||
var actualTokens_1 = getSelectedOptionTokens(answer); |
|||
var hasMatch = expectedValues.some(function (v) { return actualTokens_1.has(v); }); |
|||
return hasMatch; |
|||
} |
|||
var mainMatches = true; |
|||
var parentId = rule.parent_question_id; |
|||
if (parentId) { |
|||
var answer = answers[parentId]; |
|||
var operator = rule.operator || "any_of"; |
|||
if (operator === "exists") { |
|||
mainMatches = isAnswerPresent(answer); |
|||
} |
|||
else if (!isAnswerPresent(answer)) { |
|||
mainMatches = false; |
|||
} |
|||
else { |
|||
var actualTokens_2 = getSelectedOptionTokens(answer); |
|||
var expectedIds = (rule.trigger_option_ids || []).map(function (id) { |
|||
return String(id).toLowerCase().trim(); |
|||
}); |
|||
var isTokenMatched = function (expectedId) { |
|||
if (actualTokens_2.has(expectedId)) |
|||
return true; |
|||
var lastDot = expectedId.lastIndexOf("."); |
|||
if (lastDot !== -1 && lastDot < expectedId.length - 1) { |
|||
var suffix = expectedId.slice(lastDot + 1); |
|||
if (actualTokens_2.has(suffix)) |
|||
return true; |
|||
} |
|||
return false; |
|||
}; |
|||
if (operator === "all_of") { |
|||
mainMatches = expectedIds.length > 0 && expectedIds.every(isTokenMatched); |
|||
} |
|||
else if (operator === "equals") { |
|||
mainMatches = |
|||
expectedIds.length > 0 && |
|||
expectedIds.every(isTokenMatched) && |
|||
actualTokens_2.size <= expectedIds.length * 2; |
|||
} |
|||
else { |
|||
// default: "any_of"
|
|||
mainMatches = expectedIds.some(isTokenMatched); |
|||
} |
|||
} |
|||
} |
|||
if (rule.conditions && rule.conditions.length > 0) { |
|||
var subMatches = rule.conditions.map(function (cond) { |
|||
return ruleMatches(cond, answers, context); |
|||
}); |
|||
var condOperator = rule.conditions_operator || "all_of"; |
|||
var conditionsResult = condOperator === "any_of" |
|||
? subMatches.some(Boolean) |
|||
: subMatches.every(Boolean); |
|||
if (!parentId) { |
|||
return conditionsResult; |
|||
} |
|||
var rootOp = rule.root_operator || "all_of"; |
|||
return rootOp === "any_of" |
|||
? mainMatches || conditionsResult |
|||
: mainMatches && conditionsResult; |
|||
} |
|||
return mainMatches; |
|||
} |
|||
function isQuestionVisible(question, answers, context) { |
|||
// 1. Audience check
|
|||
if (question.audience && !matchesAudience(question.audience, context)) { |
|||
return false; |
|||
} |
|||
// 2. Canonical visibility / conditional rule
|
|||
var rule = question.visibility || |
|||
question.conditionalRule || |
|||
question.logic; |
|||
if (rule) { |
|||
return ruleMatches(rule, answers, context); |
|||
} |
|||
if (question.isVisible !== undefined) { |
|||
return question.isVisible; |
|||
} |
|||
return true; |
|||
} |
|||
function isQuestionRequired(question, answers, context) { |
|||
if (!isQuestionVisible(question, answers, context)) { |
|||
return false; |
|||
} |
|||
if (question.required || question.baseRequired) { |
|||
return true; |
|||
} |
|||
if (question.requiredWhen) { |
|||
if (question.requiredWhen.genders || question.requiredWhen.minAge || question.requiredWhen.maxAge) { |
|||
return matchesAudience(question.requiredWhen, context); |
|||
} |
|||
return ruleMatches(question.requiredWhen, answers, context); |
|||
} |
|||
return false; |
|||
} |
|||
|
Before Width: 256 | Height: 256 | Size: 28 KiB After Width: 256 | Height: 256 | Size: 31 KiB |
@ -0,0 +1,47 @@ |
|||
const fs = require('fs'); |
|||
const rules = require('./conditional-rules.js'); |
|||
|
|||
const answers = { |
|||
"family_background.number_of_siblings": { |
|||
value: 2, |
|||
option_id: null |
|||
}, |
|||
"family_background.parents_survival_status": { |
|||
value: "both_parents_are_alive", |
|||
option_id: "family_background.parents_survival_status.both_parents_are_alive" |
|||
}, |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member": { |
|||
value: "i_am_responsible_for_caring_for_my_parent(s)_(father,_mother,_or_both).", |
|||
option_id: "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both" |
|||
}, |
|||
"family_background.family_s_religious_and_ideological_atmosphere": { |
|||
value: "religious_(observant_of_obligations)", |
|||
option_id: "family_background.family_s_religious_and_ideological_atmosphere.religious_observant_of_obligations" |
|||
}, |
|||
"family_background.family_economic_status": { |
|||
value: "prosperous", |
|||
option_id: "family_background.family_economic_status.prosperous" |
|||
} |
|||
}; |
|||
|
|||
const questions = JSON.parse(fs.readFileSync('api_questions.json', 'utf8')); |
|||
const context = { age: 25, gender: 'male' }; |
|||
|
|||
for (const q of questions) { |
|||
const mapped = { |
|||
id: q.id, |
|||
type: q.type, |
|||
title: q.title, |
|||
required: q.is_required !== undefined ? q.is_required : q.required, |
|||
baseRequired: q.required, |
|||
isVisible: q.is_visible, |
|||
conditionalRule: q.conditional_rule || q.visibility || q.logic || undefined, |
|||
visibility: q.visibility || q.conditional_rule || undefined, |
|||
logic: q.logic || undefined, |
|||
requiredWhen: q.required_when || undefined |
|||
}; |
|||
|
|||
const isVis = rules.isQuestionVisible(mapped, answers, context); |
|||
const isReq = rules.isQuestionRequired(mapped, answers, context); |
|||
console.log(`Q: ${q.id} | Visible: ${isVis} | Required: ${isReq}`); |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
const fs = require('fs'); |
|||
const ts = require('typescript'); |
|||
|
|||
// Compile conditional-rules.ts on the fly
|
|||
const source = fs.readFileSync('src/lib/conditional-rules.ts', 'utf8'); |
|||
const result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS }}); |
|||
fs.writeFileSync('conditional-rules.js', result.outputText); |
|||
|
|||
const rules = require('./conditional-rules.js'); |
|||
|
|||
const answers = { |
|||
"family_background.parents_survival_status": { |
|||
value: "both_parents_are_alive", |
|||
option_id: "family_background.parents_survival_status.both_parents_are_alive" |
|||
} |
|||
}; |
|||
|
|||
const visibility = { |
|||
operator: "any_of", |
|||
parent_question_id: "family_background.parents_survival_status", |
|||
trigger_option_ids: ["family_background.parents_survival_status.both_parents_are_alive"], |
|||
clear_answer_when_hidden: true |
|||
}; |
|||
|
|||
const context = { age: 25, gender: 'male' }; |
|||
|
|||
const isVisible = rules.ruleMatches(visibility, answers, context); |
|||
console.log("Is parents_marital_status visible?", isVisible); |
|||
|
|||
@ -0,0 +1,32 @@ |
|||
const fs = require('fs'); |
|||
const rules = require('./conditional-rules.js'); |
|||
|
|||
const answers = { |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member": { |
|||
value: "i_am_responsible_for_caring_for_my_parent(s)_(father,_mother,_or_both).", |
|||
option_id: "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both" |
|||
} |
|||
}; |
|||
|
|||
const visibility = { |
|||
"operator": "any_of", |
|||
"parent_question_id": "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member", |
|||
"trigger_option_ids": [ |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both", |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_the_care_custody_or_guardianship_of_other_family_members_sibling_etc", |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_regularly_provide_financial_support_for_a_family_member_s_living_expenses", |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_father", |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_mother", |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_both_parents", |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_a_sibling_brother_sister", |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_the_legal_guardian_or_supervisor_of_a_family_member", |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_have_other_circumstances_and_will_explain_in_the_description" |
|||
], |
|||
"clear_answer_when_hidden": true |
|||
}; |
|||
|
|||
const context = { age: 25, gender: 'male' }; |
|||
|
|||
const isVisible = rules.ruleMatches(visibility, answers, context); |
|||
console.log("Is additional_details visible?", isVisible); |
|||
|
|||
@ -0,0 +1,36 @@ |
|||
const fs = require('fs'); |
|||
const rules = require('./conditional-rules.js'); |
|||
|
|||
const answers = { |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member": { |
|||
value: "i_am_responsible_for_caring_for_my_parent(s)_(father,_mother,_or_both).", |
|||
option_id: "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both" |
|||
} |
|||
}; |
|||
|
|||
const visibility = { |
|||
"operator": "any_of", |
|||
"parent_question_id": "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member", |
|||
"trigger_option_ids": [ |
|||
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both" |
|||
], |
|||
"clear_answer_when_hidden": true |
|||
}; |
|||
|
|||
const context = { age: 25, gender: 'male' }; |
|||
|
|||
const question = { |
|||
id: "family_background.additional_details_about_family_responsibility", |
|||
required: false, |
|||
baseRequired: false, // in backend DYNAMIC_REQUIRED sets is_required=True, but what does the schema send?
|
|||
isVisible: true, |
|||
conditionalRule: visibility, |
|||
visibility: visibility, |
|||
}; |
|||
|
|||
// Wait, the API sends `required: true` and `is_required: true` when it's dynamically required!
|
|||
// See check_form_section.py output: family_background.additional_details_about_family_responsibility - is_visible=True is_required=True
|
|||
question.required = true; |
|||
question.baseRequired = true; // Wait, schema adapter does baseRequired: bq.required, and required: bq.is_required !== undefined ? bq.is_required : bq.required
|
|||
|
|||
console.log("Is additional_details required?", rules.isQuestionRequired(question, answers, context)); |
|||
@ -0,0 +1,26 @@ |
|||
const fs = require('fs'); |
|||
const rules = require('./conditional-rules.js'); |
|||
|
|||
const answers = {}; |
|||
|
|||
const questions = JSON.parse(fs.readFileSync('api_questions.json', 'utf8')); |
|||
const context = { age: 25, gender: 'male' }; |
|||
|
|||
for (const q of questions) { |
|||
const mapped = { |
|||
id: q.id, |
|||
type: q.type, |
|||
title: q.title, |
|||
required: q.is_required !== undefined ? q.is_required : q.required, |
|||
baseRequired: q.required, |
|||
isVisible: q.is_visible, |
|||
conditionalRule: q.conditional_rule || q.visibility || q.logic || undefined, |
|||
visibility: q.visibility || q.conditional_rule || undefined, |
|||
logic: q.logic || undefined, |
|||
requiredWhen: q.required_when || undefined |
|||
}; |
|||
|
|||
const isVis = rules.isQuestionVisible(mapped, answers, context); |
|||
const isReq = rules.isQuestionRequired(mapped, answers, context); |
|||
console.log(`Q: ${q.id} | Visible: ${isVis} | Required: ${isReq}`); |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue