Browse Source

feat: implement testing suite and configuration files for conditional form logic and visibility rules

Dev
parent
commit
425f0f4e01
  1. 1
      api_questions.json
  2. 1
      api_questions_marital.json
  3. 237
      conditional-rules.js
  4. BIN
      public/assets/images/sections/09_identity_verification_documents.png
  5. 47
      test-all.js
  6. 29
      test-cond.js
  7. 32
      test-cond2.js
  8. 36
      test-cond3.js
  9. 26
      test-empty.js

1
api_questions.json
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

237
conditional-rules.js

@ -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;
}

BIN
public/assets/images/sections/09_identity_verification_documents.png

Before

Width: 256  |  Height: 256  |  Size: 28 KiB

After

Width: 256  |  Height: 256  |  Size: 31 KiB

47
test-all.js

@ -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}`);
}

29
test-cond.js

@ -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);

32
test-cond2.js

@ -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);

36
test-cond3.js

@ -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));

26
test-empty.js

@ -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}`);
}
Loading…
Cancel
Save