-
BINsimplified_profile_icons_02_to_11.zip
-
70src/app/questions-list/[slug]/question-detail-client.tsx
-
26src/app/questions-list/sections-request.tsx
-
43src/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
-
35src/components/Componentes/question-card.tsx
-
220src/components/Componentes/question-phone.test.tsx
-
434src/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,25 +1,58 @@ |
|||||
import type { ComponentProps } from "react"; |
import type { ComponentProps } from "react"; |
||||
|
|
||||
|
export interface LoadingBorderSpinnerProps extends ComponentProps<"span"> { |
||||
|
size?: "xs" | "sm" | "md" | "lg" | "xl"; |
||||
|
variant?: "primary" | "rose" | "blue" | "muted" | "white" | "current"; |
||||
|
} |
||||
|
|
||||
|
const sizeClasses: Record<NonNullable<LoadingBorderSpinnerProps["size"]>, string> = { |
||||
|
xs: "size-3.5 border-[1.5px]", |
||||
|
sm: "size-4 border-2", |
||||
|
md: "size-5 border-2", |
||||
|
lg: "size-8 border-[3px]", |
||||
|
xl: "size-10 border-4", |
||||
|
}; |
||||
|
|
||||
|
const variantClasses: Record<NonNullable<LoadingBorderSpinnerProps["variant"]>, string> = { |
||||
|
primary: "border-[#FC4F63]/25 border-t-[#FC4F63]", |
||||
|
rose: "border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400", |
||||
|
blue: "border-blue-500/25 border-t-blue-500 dark:border-blue-400/20 dark:border-t-blue-400", |
||||
|
muted: "border-muted-foreground/25 border-t-muted-foreground", |
||||
|
white: "border-white/30 border-t-white", |
||||
|
current: "border-current/25 border-t-current", |
||||
|
}; |
||||
|
|
||||
export function LoadingBorderSpinner({ |
export function LoadingBorderSpinner({ |
||||
|
size, |
||||
|
variant, |
||||
className = "", |
className = "", |
||||
...props |
...props |
||||
}: ComponentProps<"span">) { |
|
||||
|
}: LoadingBorderSpinnerProps) { |
||||
const hasBorder = className.split(" ").some((c) => c.startsWith("border-")); |
const hasBorder = className.split(" ").some((c) => c.startsWith("border-")); |
||||
const borderClasses = hasBorder |
|
||||
|
const defaultBorderClasses = hasBorder |
||||
? "" |
? "" |
||||
: "border-2 border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400"; |
|
||||
|
: variant |
||||
|
? variantClasses[variant] |
||||
|
: "border-2 border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400"; |
||||
|
|
||||
const hasSize = className |
const hasSize = className |
||||
.split(" ") |
.split(" ") |
||||
.some( |
.some( |
||||
(c) => c.startsWith("size-") || c.startsWith("w-") || c.startsWith("h-"), |
(c) => c.startsWith("size-") || c.startsWith("w-") || c.startsWith("h-"), |
||||
); |
); |
||||
const sizeClasses = hasSize ? "" : "size-5"; |
|
||||
|
const defaultSizeClasses = hasSize |
||||
|
? "" |
||||
|
: size |
||||
|
? sizeClasses[size] |
||||
|
: "size-5"; |
||||
|
|
||||
return ( |
return ( |
||||
<span |
<span |
||||
className={`animate-spin rounded-full inline-block animate-fade-in ${borderClasses} ${sizeClasses} ${className}`.trim()} |
|
||||
|
role="status" |
||||
|
aria-label="در حال بارگذاری" |
||||
|
className={`inline-block animate-spin rounded-full shrink-0 ${defaultBorderClasses} ${defaultSizeClasses} ${className}`.trim()} |
||||
{...props} |
{...props} |
||||
/> |
/> |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
@ -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 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", () => ({ |
vi.mock("@/translations/provider", () => ({ |
||||
useI18n: () => ({ |
useI18n: () => ({ |
||||
|
locale: "en", |
||||
dictionary: { |
dictionary: { |
||||
"Select country": "انتخاب کشور", |
|
||||
Close: "بستن", |
|
||||
Confirm: "تایید", |
|
||||
|
"Select country": "Select country", |
||||
|
Close: "Close", |
||||
|
Confirm: "Confirm", |
||||
}, |
}, |
||||
locale: "fa", |
|
||||
}), |
}), |
||||
})); |
})); |
||||
|
|
||||
vi.mock("./question-answer-storage", () => ({ |
vi.mock("./question-answer-storage", () => ({ |
||||
useQuestionAnswers: () => ({ |
useQuestionAnswers: () => ({ |
||||
getAnswerValue: () => null, |
|
||||
setAnswerValue: vi.fn(), |
|
||||
|
getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null, |
||||
|
setAnswerValue: mockSetAnswerValue, |
||||
isLoading: false, |
isLoading: false, |
||||
}), |
}), |
||||
})); |
})); |
||||
|
|
||||
describe("QuestionPhone Component", () => { |
|
||||
const mockQuestion = { |
|
||||
id: "personal_contact_number", |
|
||||
title: "شماره تماس شخصی", |
|
||||
type: "phone", |
|
||||
extras: { |
|
||||
placeHolder: "+98 9123456789", |
|
||||
}, |
|
||||
} as QuestionField; |
|
||||
|
const phoneQuestion1: QuestionField = { |
||||
|
id: "contact.personal_contact_number", |
||||
|
title: "Personal Contact Number", |
||||
|
type: "phone", |
||||
|
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" }); |
||||
|
}); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); |
||||
|
expect(screen.getByText("+98")).toBeDefined(); |
||||
|
expect(screen.getByText("🇮🇷")).toBeDefined(); |
||||
|
}); |
||||
}); |
}); |
||||
|
|
||||
it("filters countries when searching and selects a country", async () => { |
|
||||
|
it("shows default country code when IP request fails", async () => { |
||||
|
vi.spyOn(globalThis, "fetch").mockRejectedValue( |
||||
|
new Error("Network failure"), |
||||
|
); |
||||
|
|
||||
const { container } = render( |
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(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
const searchInput = screen.getByPlaceholderText("جستجو..."); |
|
||||
fireEvent.change(searchInput, { target: { value: "ایران" } }); |
|
||||
|
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, |
||||
|
), |
||||
|
); |
||||
|
|
||||
// Option should be rendered
|
|
||||
const iranOption = screen.getByRole("button", { name: /ایران/i }); |
|
||||
expect(iranOption).toBeInTheDocument(); |
|
||||
|
render( |
||||
|
<> |
||||
|
<QuestionPhone question={phoneQuestion1} /> |
||||
|
<QuestionPhone question={phoneQuestion2} /> |
||||
|
</>, |
||||
|
); |
||||
|
|
||||
fireEvent.click(iranOption); |
|
||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1); |
||||
|
|
||||
|
await act(async () => { |
||||
|
resolveIpFetch({ country_calling_code: "+98" }); |
||||
|
}); |
||||
|
|
||||
// After selection, dialog closes
|
|
||||
await waitFor(() => { |
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 |