-
BINsimplified_profile_icons_02_to_11.zip
-
70src/app/questions-list/[slug]/question-detail-client.tsx
-
26src/app/questions-list/sections-request.tsx
-
41src/components/Componentes/loading-border-spinner.tsx
-
5src/components/Componentes/loading-icon-spinner.tsx
-
5src/components/Componentes/loading-select-spinner.tsx
-
17src/components/Componentes/question-answer-storage.tsx
-
23src/components/Componentes/question-card.tsx
-
218src/components/Componentes/question-phone.test.tsx
-
390src/components/Componentes/question-phone.tsx
-
70src/components/Componentes/question-sheet.tsx
-
138src/components/Componentes/question-slider.test.tsx
-
214src/components/Componentes/question-slider.tsx
-
134src/components/Componentes/schema-question-flow.integration.test.tsx
-
39src/components/Componentes/slider-slide-two.test.tsx
-
31src/components/Componentes/slider-slide-two.tsx
-
200src/components/Componentes/ui-icon.tsx
-
5src/data/languages.ts
-
2src/hooks/marriage/types.ts
-
6src/hooks/marriage/use-form-schema.ts
-
1src/hooks/marriage/use-marriage-config.ts
-
6src/hooks/marriage/use-section-data.ts
-
203src/lib/conditional-rules.test.ts
-
304src/lib/conditional-rules.ts
-
23src/lib/schema-adapter-overview.test.ts
-
113src/lib/schema-adapter.ts
-
39src/lib/ssr-fetch.ts
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/02_contact_residence_family_communication.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/03_physical_appearance_health.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/04_education_career_economic_status.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/05_family_background.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/06_marital_status_marriage_history_children.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/07_beliefs_lifestyle_personal_boundaries.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/08_future_spouse_criteria_red_lines.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/09_identity_verification_documents.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/10_personality_test.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/11_glasser_5_needs_test.png
@ -1,82 +1,210 @@ |
|||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; |
|||
import { afterEach, describe, expect, it, vi } from "vitest"; |
|||
import { |
|||
act, |
|||
cleanup, |
|||
fireEvent, |
|||
render, |
|||
screen, |
|||
waitFor, |
|||
} from "@testing-library/react"; |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import type { QuestionField } from "@/lib/schema-adapter"; |
|||
import { QuestionPhone } from "./question-phone"; |
|||
import { QuestionPhone, resetGeoPhoneStateForTesting } from "./question-phone"; |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
let answerMap: Record<string, unknown> = {}; |
|||
const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => { |
|||
answerMap[q.id] = val; |
|||
}); |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: () => ({ |
|||
locale: "en", |
|||
dictionary: { |
|||
"Select country": "انتخاب کشور", |
|||
Close: "بستن", |
|||
Confirm: "تایید", |
|||
"Select country": "Select country", |
|||
Close: "Close", |
|||
Confirm: "Confirm", |
|||
}, |
|||
locale: "fa", |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("./question-answer-storage", () => ({ |
|||
useQuestionAnswers: () => ({ |
|||
getAnswerValue: () => null, |
|||
setAnswerValue: vi.fn(), |
|||
getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null, |
|||
setAnswerValue: mockSetAnswerValue, |
|||
isLoading: false, |
|||
}), |
|||
})); |
|||
|
|||
describe("QuestionPhone Component", () => { |
|||
const mockQuestion = { |
|||
id: "personal_contact_number", |
|||
title: "شماره تماس شخصی", |
|||
const phoneQuestion1: QuestionField = { |
|||
id: "contact.personal_contact_number", |
|||
title: "Personal Contact Number", |
|||
type: "phone", |
|||
extras: { |
|||
placeHolder: "+98 9123456789", |
|||
}, |
|||
} as QuestionField; |
|||
order: 1, |
|||
required: true, |
|||
baseRequired: true, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "+44 7911 123456", range: [0, 0], options: [] }, |
|||
options: [], |
|||
}; |
|||
|
|||
const phoneQuestion2: QuestionField = { |
|||
id: "contact.representative_contact_number", |
|||
title: "Representative's Contact Number", |
|||
type: "phone", |
|||
order: 2, |
|||
required: false, |
|||
baseRequired: false, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "+44 7911 123456", range: [0, 0], options: [] }, |
|||
options: [], |
|||
}; |
|||
|
|||
|
|||
|
|||
describe("QuestionPhone IP country detection and shimmer", () => { |
|||
beforeEach(() => { |
|||
answerMap = {}; |
|||
mockSetAnswerValue.mockClear(); |
|||
localStorage.clear(); |
|||
resetGeoPhoneStateForTesting(); |
|||
vi.restoreAllMocks(); |
|||
}); |
|||
|
|||
it("renders phone input and opens sheet on country code click", async () => { |
|||
render(<QuestionPhone question={mockQuestion} countryCode="+98" />); |
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
const countryButton = screen.getByRole("button", { name: /\+98/i }); |
|||
expect(countryButton).toBeInTheDocument(); |
|||
it("renders shimmer on country button while IP request is pending, then shows resolved country code", async () => { |
|||
let resolveIpFetch!: (value: unknown) => void; |
|||
const ipPromise = new Promise((resolve) => { |
|||
resolveIpFetch = resolve; |
|||
}); |
|||
|
|||
fireEvent.click(countryButton); |
|||
vi.spyOn(globalThis, "fetch").mockImplementation(() => |
|||
ipPromise.then( |
|||
(data) => |
|||
({ |
|||
ok: true, |
|||
json: async () => data, |
|||
}) as unknown as Response, |
|||
), |
|||
); |
|||
|
|||
const dialog = screen.getByRole("dialog"); |
|||
expect(dialog).toBeInTheDocument(); |
|||
expect(screen.getByText("انتخاب کشور")).toBeInTheDocument(); |
|||
const { container } = render(<QuestionPhone question={phoneQuestion1} />); |
|||
|
|||
// Close button exists in header
|
|||
const closeBtn = screen.getByLabelText("Close"); |
|||
expect(closeBtn).toBeInTheDocument(); |
|||
const shimmerElements = container.querySelectorAll(".shimmer-bg"); |
|||
expect(shimmerElements.length).toBeGreaterThan(0); |
|||
const input = screen.getByRole("textbox"); |
|||
expect(input.classList.contains("shimmer-bg")).toBe(false); |
|||
|
|||
// Search input is present with translated placeholder
|
|||
const searchInput = screen.getByPlaceholderText("جستجو..."); |
|||
expect(searchInput).toBeInTheDocument(); |
|||
await act(async () => { |
|||
resolveIpFetch({ country_calling_code: "+98" }); |
|||
}); |
|||
|
|||
it("filters countries when searching and selects a country", async () => { |
|||
await waitFor(() => { |
|||
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); |
|||
expect(screen.getByText("+98")).toBeDefined(); |
|||
expect(screen.getByText("🇮🇷")).toBeDefined(); |
|||
}); |
|||
}); |
|||
|
|||
it("shows default country code when IP request fails", async () => { |
|||
vi.spyOn(globalThis, "fetch").mockRejectedValue( |
|||
new Error("Network failure"), |
|||
); |
|||
|
|||
const { container } = render( |
|||
<QuestionPhone question={mockQuestion} countryCode="+44" />, |
|||
<QuestionPhone question={phoneQuestion1} countryCode="+44" />, |
|||
); |
|||
|
|||
const triggerButton = container.querySelector("button")!; |
|||
fireEvent.click(triggerButton); |
|||
await waitFor(() => { |
|||
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); |
|||
expect(screen.getByText("+44")).toBeDefined(); |
|||
expect(screen.getByText("🇬🇧")).toBeDefined(); |
|||
}); |
|||
}); |
|||
|
|||
it("fetches IP country code only once when multiple fields are rendered and updates both", async () => { |
|||
let resolveIpFetch!: (value: unknown) => void; |
|||
const ipPromise = new Promise((resolve) => { |
|||
resolveIpFetch = resolve; |
|||
}); |
|||
|
|||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(() => |
|||
ipPromise.then( |
|||
(data) => |
|||
({ |
|||
ok: true, |
|||
json: async () => data, |
|||
}) as unknown as Response, |
|||
), |
|||
); |
|||
|
|||
const searchInput = screen.getByPlaceholderText("جستجو..."); |
|||
fireEvent.change(searchInput, { target: { value: "ایران" } }); |
|||
render( |
|||
<> |
|||
<QuestionPhone question={phoneQuestion1} /> |
|||
<QuestionPhone question={phoneQuestion2} /> |
|||
</>, |
|||
); |
|||
|
|||
// Option should be rendered
|
|||
const iranOption = screen.getByRole("button", { name: /ایران/i }); |
|||
expect(iranOption).toBeInTheDocument(); |
|||
expect(fetchSpy).toHaveBeenCalledTimes(1); |
|||
|
|||
fireEvent.click(iranOption); |
|||
await act(async () => { |
|||
resolveIpFetch({ country_calling_code: "+98" }); |
|||
}); |
|||
|
|||
// After selection, dialog closes
|
|||
await waitFor(() => { |
|||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); |
|||
const irCodes = screen.getAllByText("+98"); |
|||
expect(irCodes.length).toBe(2); |
|||
const flags = screen.getAllByText("🇮🇷"); |
|||
expect(flags.length).toBe(2); |
|||
}); |
|||
}); |
|||
|
|||
it("does not show shimmer and uses saved profile value if already present", async () => { |
|||
answerMap[phoneQuestion1.id] = { |
|||
countryCode: "1", |
|||
phoneNumber: "2025550143", |
|||
}; |
|||
|
|||
const fetchSpy = vi.spyOn(globalThis, "fetch"); |
|||
|
|||
const { container } = render(<QuestionPhone question={phoneQuestion1} />); |
|||
|
|||
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); |
|||
expect(screen.getByText("+1")).toBeDefined(); |
|||
expect(screen.getByDisplayValue("2025550143")).toBeDefined(); |
|||
expect(fetchSpy).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("does not overwrite manual selection when user manually interacts", async () => { |
|||
let resolveIpFetch!: (value: unknown) => void; |
|||
const ipPromise = new Promise((resolve) => { |
|||
resolveIpFetch = resolve; |
|||
}); |
|||
|
|||
vi.spyOn(globalThis, "fetch").mockImplementation(() => |
|||
ipPromise.then( |
|||
(data) => |
|||
({ |
|||
ok: true, |
|||
json: async () => data, |
|||
}) as unknown as Response, |
|||
), |
|||
); |
|||
|
|||
render(<QuestionPhone question={phoneQuestion1} />); |
|||
|
|||
const input = screen.getByRole("textbox"); |
|||
fireEvent.change(input, { target: { value: "123456" } }); |
|||
|
|||
await act(async () => { |
|||
resolveIpFetch({ country_calling_code: "+98" }); |
|||
}); |
|||
|
|||
expect(screen.getByDisplayValue("123456")).toBeDefined(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,138 @@ |
|||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import type { QuestionField } from "@/lib/schema-adapter"; |
|||
import { mapBackendQuestionToFrontend } from "@/lib/schema-adapter"; |
|||
import { QuestionSlider } from "./question-slider"; |
|||
|
|||
let answerValue: any = null; |
|||
const setAnswerValueMock = vi.fn(); |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: () => ({ |
|||
locale: "en", |
|||
dictionary: { From: "From", To: "To" }, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("./question-answer-storage", () => ({ |
|||
useQuestionAnswers: () => ({ |
|||
getAnswerValue: () => answerValue, |
|||
setAnswerValue: setAnswerValueMock, |
|||
}), |
|||
})); |
|||
|
|||
describe("QuestionSlider", () => { |
|||
beforeEach(() => { |
|||
answerValue = null; |
|||
setAnswerValueMock.mockClear(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it("extracts range correctly from backend validation schema", () => { |
|||
const backendQuestion = { |
|||
id: "appearance_health_activity.height_in_centimeters", |
|||
title: "Height in Centimeters", |
|||
type: "scale", |
|||
order: 1, |
|||
required: true, |
|||
is_required: true, |
|||
show_guardian_notice: false, |
|||
validation: { min: 100, max: 230 }, |
|||
ui_config: { placeholder_en: "175", private: true }, |
|||
logic: null, |
|||
is_visible: true, |
|||
options: [], |
|||
description: "", |
|||
tooltip: "", |
|||
placeholder: "175", |
|||
}; |
|||
|
|||
const frontendQ = mapBackendQuestionToFrontend(backendQuestion as any, 0); |
|||
expect(frontendQ.extras.range).toEqual([100, 230]); |
|||
}); |
|||
|
|||
it("renders scale marks correctly across the range", () => { |
|||
const heightQuestion = { |
|||
id: "appearance_health_activity.height_in_centimeters", |
|||
title: "Height in Centimeters", |
|||
type: "scale", |
|||
order: 1, |
|||
required: true, |
|||
baseRequired: true, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "175", range: [100, 230], options: [] }, |
|||
options: [], |
|||
validation: { min: 100, max: 230 }, |
|||
} as QuestionField; |
|||
|
|||
answerValue = 178; |
|||
render(<QuestionSlider question={heightQuestion} />); |
|||
|
|||
const slider = screen.getByRole("slider") as HTMLInputElement; |
|||
expect(slider.min).toBe("100"); |
|||
expect(slider.max).toBe("230"); |
|||
expect(slider.value).toBe("178"); |
|||
|
|||
// The value badge shows 178
|
|||
expect(screen.getByText("178")).toBeDefined(); |
|||
|
|||
// Scale marks should be rendered with multiple ticks (100, 120, ..., 230)
|
|||
expect(screen.getByText("100")).toBeDefined(); |
|||
expect(screen.getByText("230")).toBeDefined(); |
|||
expect(screen.queryByText("0")).toBeNull(); |
|||
}); |
|||
|
|||
it("updates answer value when slider changes", () => { |
|||
const weightQuestion = { |
|||
id: "appearance_health_activity.weight_in_kilograms", |
|||
title: "Weight in Kilograms", |
|||
type: "scale", |
|||
order: 2, |
|||
required: true, |
|||
baseRequired: true, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "70", range: [40, 180], options: [] }, |
|||
options: [], |
|||
validation: { min: 40, max: 180 }, |
|||
} as QuestionField; |
|||
|
|||
render(<QuestionSlider question={weightQuestion} />); |
|||
|
|||
const slider = screen.getByRole("slider"); |
|||
fireEvent.change(slider, { target: { value: "85" } }); |
|||
|
|||
expect(setAnswerValueMock).toHaveBeenCalledWith(weightQuestion, 85); |
|||
}); |
|||
|
|||
it("falls back gracefully when range is not in extras but in validation", () => { |
|||
const customQuestion = { |
|||
id: "custom_scale", |
|||
title: "Satisfaction", |
|||
type: "scale", |
|||
order: 3, |
|||
required: true, |
|||
baseRequired: true, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "5", range: [0, 0], options: [] }, |
|||
options: [], |
|||
validation: { min: 1, max: 10 }, |
|||
} as QuestionField; |
|||
|
|||
render(<QuestionSlider question={customQuestion} />); |
|||
|
|||
const slider = screen.getByRole("slider") as HTMLInputElement; |
|||
expect(slider.min).toBe("1"); |
|||
expect(slider.max).toBe("10"); |
|||
expect(screen.getByText("1")).toBeDefined(); |
|||
expect(screen.getByText("10")).toBeDefined(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,39 @@ |
|||
import { render, screen, fireEvent, cleanup } from "@testing-library/react"; |
|||
import { describe, expect, it, afterEach, vi } from "vitest"; |
|||
import { SliderSlideTwo } from "./slider-slide-two"; |
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
|
|||
vi.mock("@/hooks/marriage/use-marriage-config", () => ({ |
|||
useMarriageConfigQuery: () => ({ |
|||
data: { |
|||
intro_video_url: "https://example.com/video.mp4", |
|||
intro_video_thumbnail_url: "/assets/images/Frame 2095586523.png", |
|||
video_step_2_url: "https://example.com/video2.mp4", |
|||
video_step_2_thumbnail_url: "/assets/images/Frame 20953586523.png", |
|||
}, |
|||
}), |
|||
})); |
|||
|
|||
describe("SliderSlideTwo", () => { |
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it("renders video card with portrait thumbnail and play button", () => { |
|||
const queryClient = new QueryClient(); |
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<SliderSlideTwo index={1} /> |
|||
</QueryClientProvider>, |
|||
); |
|||
|
|||
const videoImg = screen.getByAltText("video"); |
|||
expect(videoImg).toBeDefined(); |
|||
expect(videoImg.getAttribute("src")).toContain("Frame%2020953586523.png"); |
|||
|
|||
const playBtn = screen.getByAltText("play"); |
|||
expect(playBtn).toBeDefined(); |
|||
|
|||
expect(screen.getByText("Dr. Hasti Masoudi")).toBeDefined(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,203 @@ |
|||
import { describe, it, expect } from "vitest"; |
|||
import { |
|||
canonicalRule, |
|||
ruleMatches, |
|||
isQuestionVisible, |
|||
isQuestionRequired, |
|||
} from "./conditional-rules"; |
|||
import type { QuestionField } from "./schema-adapter"; |
|||
|
|||
describe("Conditional Rules Evaluator", () => { |
|||
const dummyQuestion: QuestionField = { |
|||
id: "child.q", |
|||
title: "Child Question", |
|||
type: "text", |
|||
order: 1, |
|||
required: false, |
|||
baseRequired: false, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "", range: [0, 0], options: [] }, |
|||
options: [], |
|||
}; |
|||
|
|||
it("should evaluate single trigger option match correctly", () => { |
|||
const rule = { |
|||
parent_question_id: "appearance_health.physical_health_status", |
|||
trigger_option_ids: [ |
|||
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness", |
|||
"appearance_health.physical_health_status.i_have_a_physical_deformity_disability_or_limitation", |
|||
], |
|||
operator: "any_of", |
|||
}; |
|||
|
|||
// 1. When parent is not answered
|
|||
expect(ruleMatches(rule, {})).toBe(false); |
|||
|
|||
// 2. When parent is answered with non-matching option
|
|||
expect( |
|||
ruleMatches(rule, { |
|||
"appearance_health.physical_health_status": { |
|||
value: "i_am_in_perfect_health", |
|||
option_id: "appearance_health.physical_health_status.i_am_in_perfect_health", |
|||
}, |
|||
}), |
|||
).toBe(false); |
|||
|
|||
// 3. When parent is answered with matching option (full slug)
|
|||
expect( |
|||
ruleMatches(rule, { |
|||
"appearance_health.physical_health_status": { |
|||
value: "i_have_a_specific_or_chronic_illness", |
|||
option_id: |
|||
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness", |
|||
}, |
|||
}), |
|||
).toBe(true); |
|||
|
|||
// 4. When parent value only contains the short value
|
|||
expect( |
|||
ruleMatches(rule, { |
|||
"appearance_health.physical_health_status": { |
|||
value: "i_have_a_physical_deformity_disability_or_limitation", |
|||
}, |
|||
}), |
|||
).toBe(true); |
|||
}); |
|||
|
|||
it("should evaluate complex nested conditions (e.g. number of children rule)", () => { |
|||
// marital_history.number_of_children:
|
|||
// parent: children_and_guardianship_status (have_children_living_with_me, have_children_not_living_with_me)
|
|||
// conditions: current_marital_status in (divorced_after_living_together, widowed)
|
|||
const rule = { |
|||
parent_question_id: "marital_history.children_and_guardianship_status", |
|||
trigger_option_ids: [ |
|||
"marital_history.children_and_guardianship_status.have_children_living_with_me", |
|||
"marital_history.children_and_guardianship_status.have_children_not_living_with_me", |
|||
], |
|||
operator: "any_of", |
|||
conditions: [ |
|||
{ |
|||
parent_question_id: "marital_history.current_marital_status", |
|||
trigger_option_ids: [ |
|||
"marital_history.current_marital_status.divorced_after_living_together", |
|||
"marital_history.current_marital_status.widowed", |
|||
], |
|||
operator: "any_of", |
|||
}, |
|||
], |
|||
conditions_operator: "all_of", |
|||
root_operator: "all_of", |
|||
}; |
|||
|
|||
// Case 1: Single, no children -> false
|
|||
expect( |
|||
ruleMatches(rule, { |
|||
"marital_history.current_marital_status": { |
|||
value: "single_never_married", |
|||
}, |
|||
"marital_history.children_and_guardianship_status": { |
|||
value: "no_children", |
|||
}, |
|||
}), |
|||
).toBe(false); |
|||
|
|||
// Case 2: Divorced, but no children -> false
|
|||
expect( |
|||
ruleMatches(rule, { |
|||
"marital_history.current_marital_status": { |
|||
value: "divorced_after_living_together", |
|||
option_id: |
|||
"marital_history.current_marital_status.divorced_after_living_together", |
|||
}, |
|||
"marital_history.children_and_guardianship_status": { |
|||
value: "no_children", |
|||
option_id: |
|||
"marital_history.children_and_guardianship_status.no_children", |
|||
}, |
|||
}), |
|||
).toBe(false); |
|||
|
|||
// Case 3: Divorced AND has children -> true
|
|||
expect( |
|||
ruleMatches(rule, { |
|||
"marital_history.current_marital_status": { |
|||
value: "divorced_after_living_together", |
|||
option_id: |
|||
"marital_history.current_marital_status.divorced_after_living_together", |
|||
}, |
|||
"marital_history.children_and_guardianship_status": { |
|||
value: "have_children_living_with_me", |
|||
option_id: |
|||
"marital_history.children_and_guardianship_status.have_children_living_with_me", |
|||
}, |
|||
}), |
|||
).toBe(true); |
|||
}); |
|||
|
|||
it("should evaluate audience rules by gender and age", () => { |
|||
const questionForMen: QuestionField = { |
|||
...dummyQuestion, |
|||
id: "education_career.ability_to_support_marriage_expenses", |
|||
audience: { |
|||
genders: ["male"], |
|||
}, |
|||
}; |
|||
|
|||
const questionForWomen: QuestionField = { |
|||
...dummyQuestion, |
|||
id: "beliefs_lifestyle.makeup_in_public", |
|||
audience: { |
|||
genders: ["female"], |
|||
}, |
|||
}; |
|||
|
|||
// Male user
|
|||
expect(isQuestionVisible(questionForMen, {}, { gender: "male" })).toBe(true); |
|||
expect(isQuestionVisible(questionForWomen, {}, { gender: "male" })).toBe(false); |
|||
|
|||
// Female user
|
|||
expect(isQuestionVisible(questionForMen, {}, { gender: "female" })).toBe(false); |
|||
expect(isQuestionVisible(questionForWomen, {}, { gender: "female" })).toBe(true); |
|||
}); |
|||
|
|||
it("should evaluate dynamic requirement via requiredWhen", () => { |
|||
const q: QuestionField = { |
|||
...dummyQuestion, |
|||
required: false, |
|||
baseRequired: false, |
|||
requiredWhen: { |
|||
parent_question_id: "family_background.parents_marital_status", |
|||
trigger_option_ids: [ |
|||
"family_background.parents_marital_status.i_have_special_family_circumstances_and_will_provide_the_details_in_the_description", |
|||
], |
|||
operator: "any_of", |
|||
}, |
|||
conditionalRule: { |
|||
parent_question_id: "family_background.parents_marital_status", |
|||
trigger_option_ids: [ |
|||
"family_background.parents_marital_status.i_have_special_family_circumstances_and_will_provide_the_details_in_the_description", |
|||
], |
|||
operator: "any_of", |
|||
}, |
|||
}; |
|||
|
|||
// When condition not met -> not visible, not required
|
|||
expect(isQuestionVisible(q, {})).toBe(false); |
|||
expect(isQuestionRequired(q, {})).toBe(false); |
|||
|
|||
// When condition met -> visible AND required
|
|||
const matchingAnswers = { |
|||
"family_background.parents_marital_status": { |
|||
value: |
|||
"i_have_special_family_circumstances_and_will_provide_the_details_in_the_description", |
|||
option_id: |
|||
"family_background.parents_marital_status.i_have_special_family_circumstances_and_will_provide_the_details_in_the_description", |
|||
}, |
|||
}; |
|||
|
|||
expect(isQuestionVisible(q, matchingAnswers)).toBe(true); |
|||
expect(isQuestionRequired(q, matchingAnswers)).toBe(true); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,304 @@ |
|||
import type { QuestionField } from "./schema-adapter"; |
|||
|
|||
export type CanonicalRule = { |
|||
parent_question_id?: string; |
|||
trigger_option_ids?: string[]; |
|||
operator?: "any_of" | "all_of" | "equals" | "exists"; |
|||
clear_answer_when_hidden?: boolean; |
|||
audience?: { |
|||
genders?: string[]; |
|||
minAge?: number; |
|||
maxAge?: number; |
|||
}; |
|||
conditions?: CanonicalRule[]; |
|||
conditions_operator?: "any_of" | "all_of"; |
|||
root_operator?: "any_of" | "all_of"; |
|||
dependsOn?: { |
|||
key?: string; |
|||
values?: string[]; |
|||
}; |
|||
}; |
|||
|
|||
export type UserContext = { |
|||
gender?: string | null; |
|||
age?: number | null; |
|||
}; |
|||
|
|||
export function canonicalRule(rule: any): CanonicalRule | null { |
|||
if (!rule || typeof rule !== "object") { |
|||
return null; |
|||
} |
|||
|
|||
// If wrapped in dependsOn (legacy)
|
|||
if (rule.dependsOn && !rule.parent_question_id) { |
|||
return { |
|||
dependsOn: rule.dependsOn, |
|||
}; |
|||
} |
|||
|
|||
const operator = ["any_of", "all_of", "equals", "exists"].includes( |
|||
rule.operator, |
|||
) |
|||
? rule.operator |
|||
: "any_of"; |
|||
|
|||
let optionIds: string[] = []; |
|||
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)]; |
|||
} |
|||
|
|||
const result: CanonicalRule = { |
|||
parent_question_id: rule.parent_question_id || rule.parentQuestionId, |
|||
trigger_option_ids: optionIds, |
|||
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((c: CanonicalRule | null): c is CanonicalRule => 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; |
|||
} |
|||
|
|||
export function isAnswerPresent(answer: any): boolean { |
|||
if (answer === undefined || answer === null) { |
|||
return false; |
|||
} |
|||
const 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: any): Set<string> { |
|||
const tokens = new Set<string>(); |
|||
if (!answer) return tokens; |
|||
|
|||
const rawOptionId = |
|||
typeof answer === "object" && "option_id" in answer |
|||
? answer.option_id |
|||
: undefined; |
|||
const rawValue = |
|||
typeof answer === "object" && "value" in answer ? answer.value : answer; |
|||
|
|||
const addToken = (item: any) => { |
|||
if (item === undefined || item === null) return; |
|||
const 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"
|
|||
const 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; |
|||
} |
|||
|
|||
export function matchesAudience( |
|||
audience: { genders?: string[]; minAge?: number; maxAge?: number } | undefined, |
|||
context?: UserContext, |
|||
): boolean { |
|||
if (!audience || typeof audience !== "object") { |
|||
return true; |
|||
} |
|||
|
|||
if (audience.genders && audience.genders.length > 0) { |
|||
if (context?.gender && !audience.genders.includes(context.gender)) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
if (audience.minAge !== undefined && context?.age !== undefined && context.age !== null) { |
|||
if (context.age < audience.minAge) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
if (audience.maxAge !== undefined && context?.age !== undefined && context.age !== null) { |
|||
if (context.age > audience.maxAge) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
export function ruleMatches( |
|||
rawRule: any, |
|||
answers: Record<string, any>, |
|||
context?: UserContext, |
|||
): boolean { |
|||
const 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) { |
|||
const parentId = rule.dependsOn.key; |
|||
const answer = answers[parentId]; |
|||
if (!isAnswerPresent(answer)) { |
|||
return false; |
|||
} |
|||
const expectedValues = (rule.dependsOn.values || []).map((v) => |
|||
String(v).toLowerCase().trim(), |
|||
); |
|||
const actualTokens = getSelectedOptionTokens(answer); |
|||
const hasMatch = expectedValues.some((v) => actualTokens.has(v)); |
|||
return hasMatch; |
|||
} |
|||
|
|||
let mainMatches = true; |
|||
const parentId = rule.parent_question_id; |
|||
|
|||
if (parentId) { |
|||
const answer = answers[parentId]; |
|||
const operator = rule.operator || "any_of"; |
|||
|
|||
if (operator === "exists") { |
|||
mainMatches = isAnswerPresent(answer); |
|||
} else if (!isAnswerPresent(answer)) { |
|||
mainMatches = false; |
|||
} else { |
|||
const actualTokens = getSelectedOptionTokens(answer); |
|||
const expectedIds = (rule.trigger_option_ids || []).map((id) => |
|||
String(id).toLowerCase().trim(), |
|||
); |
|||
|
|||
const isTokenMatched = (expectedId: string) => { |
|||
if (actualTokens.has(expectedId)) return true; |
|||
const lastDot = expectedId.lastIndexOf("."); |
|||
if (lastDot !== -1 && lastDot < expectedId.length - 1) { |
|||
const suffix = expectedId.slice(lastDot + 1); |
|||
if (actualTokens.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.size <= expectedIds.length * 2; |
|||
} else { |
|||
// default: "any_of"
|
|||
mainMatches = expectedIds.some(isTokenMatched); |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (rule.conditions && rule.conditions.length > 0) { |
|||
const subMatches = rule.conditions.map((cond) => |
|||
ruleMatches(cond, answers, context), |
|||
); |
|||
const condOperator = rule.conditions_operator || "all_of"; |
|||
const conditionsResult = |
|||
condOperator === "any_of" |
|||
? subMatches.some(Boolean) |
|||
: subMatches.every(Boolean); |
|||
|
|||
if (!parentId) { |
|||
return conditionsResult; |
|||
} |
|||
|
|||
const rootOp = rule.root_operator || "all_of"; |
|||
return rootOp === "any_of" |
|||
? mainMatches || conditionsResult |
|||
: mainMatches && conditionsResult; |
|||
} |
|||
|
|||
return mainMatches; |
|||
} |
|||
|
|||
export function isQuestionVisible( |
|||
question: QuestionField, |
|||
answers: Record<string, any>, |
|||
context?: UserContext, |
|||
): boolean { |
|||
// 1. Audience check
|
|||
if (question.audience && !matchesAudience(question.audience, context)) { |
|||
return false; |
|||
} |
|||
|
|||
// 2. Canonical visibility / conditional rule
|
|||
const rule = |
|||
question.visibility || |
|||
question.conditionalRule || |
|||
question.logic; |
|||
|
|||
if (rule) { |
|||
return ruleMatches(rule, answers, context); |
|||
} |
|||
|
|||
if (question.isVisible !== undefined) { |
|||
return question.isVisible; |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
export function isQuestionRequired( |
|||
question: QuestionField, |
|||
answers: Record<string, any>, |
|||
context?: UserContext, |
|||
): boolean { |
|||
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; |
|||
} |
|||
|
After Width: 1254 | Height: 1254 | Size: 987 KiB |
|
After Width: 1254 | Height: 1254 | Size: 952 KiB |
|
After Width: 1254 | Height: 1254 | Size: 980 KiB |
|
After Width: 1254 | Height: 1254 | Size: 1.0 MiB |
|
After Width: 1254 | Height: 1254 | Size: 805 KiB |
|
After Width: 1254 | Height: 1254 | Size: 1018 KiB |
|
After Width: 1254 | Height: 1254 | Size: 899 KiB |
|
After Width: 1254 | Height: 1254 | Size: 1021 KiB |
|
After Width: 1254 | Height: 1254 | Size: 1021 KiB |
|
After Width: 1254 | Height: 1254 | Size: 794 KiB |