78 changed files with 2079 additions and 7516 deletions
-
30package-lock.json
-
11src/app/[lang]/questions-list/[slug]/page.tsx
-
5src/app/api/proxy/route.ts
-
82src/app/intro/page.tsx
-
2src/app/new-match/page.tsx
-
25src/app/questions-list/[slug]/page.tsx
-
275src/app/questions-list/[slug]/question-detail-client.test.tsx
-
512src/app/questions-list/[slug]/question-detail-client.tsx
-
89src/app/questions-list/page.tsx
-
6src/app/questions-list/sections-request.tsx
-
100src/app/terms/page.test.tsx
-
59src/app/terms/page.tsx
-
72src/components/Componentes/conditional-questions.tsx
-
10src/components/Componentes/fix-to-the-end.tsx
-
524src/components/Componentes/progress-helper.ts
-
171src/components/Componentes/question-answer-storage.tsx
-
260src/components/Componentes/question-answer.test.tsx
-
12src/components/Componentes/question-birthplace.tsx
-
6src/components/Componentes/question-button.tsx
-
2src/components/Componentes/question-card.tsx
-
49src/components/Componentes/question-checkbox.tsx
-
38src/components/Componentes/question-date.tsx
-
188src/components/Componentes/question-dropdown.tsx
-
18src/components/Componentes/question-exit-navigation-button.tsx
-
20src/components/Componentes/question-file.tsx
-
38src/components/Componentes/question-number.tsx
-
8src/components/Componentes/question-phone.tsx
-
18src/components/Componentes/question-photo.tsx
-
28src/components/Componentes/question-radio.tsx
-
29src/components/Componentes/question-renderer.tsx
-
22src/components/Componentes/question-section-flow.tsx
-
14src/components/Componentes/question-slider.tsx
-
35src/components/Componentes/question-text.tsx
-
10src/components/Componentes/question-textarea.tsx
-
49src/components/Componentes/question-title.tsx
-
78src/components/Componentes/required-steps-card.tsx
-
235src/components/Componentes/schema-question-flow.integration.test.tsx
-
265src/components/Componentes/slider-page.test.tsx
-
96src/components/Componentes/slider-page.tsx
-
17src/components/Componentes/slider-slide-one.tsx
-
1src/components/Componentes/test-questions-flow.tsx
-
114src/components/Componentes/ui-config.test.tsx
-
1067src/data/cattell-fallback.ts
-
183src/data/glasser-fallback.ts
-
345src/data/question-data.ts
-
1870src/data/questions/en.json
-
1870src/data/questions/fa.json
-
47src/data/section-slug-map.ts
-
4src/hooks/marriage/types.ts
-
9src/hooks/marriage/use-form-schema.ts
-
196src/hooks/marriage/use-section-data.ts
-
27src/lib/auth-bridge.ts
-
88src/lib/get-submit-path.test.ts
-
144src/lib/schema-adapter.ts
-
5src/translations/locales/ar.json
-
5src/translations/locales/az.json
-
5src/translations/locales/bn.json
-
5src/translations/locales/da.json
-
5src/translations/locales/de.json
-
6src/translations/locales/en.json
-
5src/translations/locales/es.json
-
6src/translations/locales/fa.json
-
5src/translations/locales/fr.json
-
5src/translations/locales/gu.json
-
5src/translations/locales/ha.json
-
5src/translations/locales/he.json
-
5src/translations/locales/hi.json
-
5src/translations/locales/id.json
-
5src/translations/locales/ks.json
-
5src/translations/locales/pt.json
-
5src/translations/locales/ru.json
-
5src/translations/locales/sw.json
-
5src/translations/locales/tg.json
-
5src/translations/locales/tr.json
-
5src/translations/locales/ul.json
-
5src/translations/locales/ur.json
-
5src/translations/locales/uz.json
-
5src/translations/locales/zh.json
@ -1,14 +1,3 @@ |
|||
import QuestionDetailPage from "@/app/questions-list/[slug]/page"; |
|||
import { getQuestionListItems } from "@/data/question-data"; |
|||
import { locales } from "@/translations/config"; |
|||
|
|||
export function generateStaticParams() { |
|||
return locales.flatMap((lang) => |
|||
getQuestionListItems(lang).map((item) => ({ |
|||
lang, |
|||
slug: item.slug, |
|||
})), |
|||
); |
|||
} |
|||
|
|||
export default QuestionDetailPage; |
|||
@ -0,0 +1,275 @@ |
|||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; |
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
|||
import QuestionDetailClient from "./question-detail-client"; |
|||
import { useCattellQuestionsQuery } from "@/hooks/marriage/use-cattell"; |
|||
import { useGlasserQuestionsQuery } from "@/hooks/marriage/use-glasser"; |
|||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|||
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; |
|||
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; |
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
|
|||
vi.mock("next/navigation", () => ({ |
|||
useRouter: vi.fn(() => ({ replace: vi.fn(), push: vi.fn() })), |
|||
})); |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: vi.fn(() => ({ locale: "en", dictionary: new Proxy({}, { get: (_, key) => key }) })), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-cattell", () => ({ |
|||
useCattellQuestionsQuery: vi.fn(), |
|||
useSubmitCattellAssessmentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-glasser", () => ({ |
|||
useGlasserQuestionsQuery: vi.fn(), |
|||
useSubmitGlasserAssessmentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
|||
useMarriageProfileQuery: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-form-schema", () => ({ |
|||
useFormSchemaQuery: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({ |
|||
useHabcoinPaymentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })), |
|||
})); |
|||
|
|||
vi.mock("@/lib/schema-adapter", () => ({ |
|||
convertSchemaToFrontendItems: vi.fn(), |
|||
})); |
|||
|
|||
describe("QuestionDetailClient Validation", () => { |
|||
const mockCattellRefetch = vi.fn(); |
|||
const mockGlasserRefetch = vi.fn(); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
|
|||
(useMarriageProfileQuery as any).mockReturnValue({ |
|||
data: { age: 30, gender: "male" }, |
|||
isLoading: false, |
|||
}); |
|||
|
|||
(useFormSchemaQuery as any).mockReturnValue({ |
|||
data: {}, |
|||
isLoading: false, |
|||
}); |
|||
}); |
|||
|
|||
const setupTest = (slug: string, cattellData: any, glasserData: any) => { |
|||
// Mock schema adapter to return an item for the requested slug
|
|||
(convertSchemaToFrontendItems as any).mockReturnValue([ |
|||
{ |
|||
slug: slug, |
|||
title: "Test", |
|||
questions: [] |
|||
} |
|||
]); |
|||
|
|||
(useCattellQuestionsQuery as any).mockReturnValue({ |
|||
data: cattellData, |
|||
isLoading: false, |
|||
isError: false, |
|||
refetch: mockCattellRefetch, |
|||
}); |
|||
|
|||
(useGlasserQuestionsQuery as any).mockReturnValue({ |
|||
data: glasserData, |
|||
isLoading: false, |
|||
isError: false, |
|||
refetch: mockGlasserRefetch, |
|||
}); |
|||
|
|||
const queryClient = new QueryClient({ |
|||
defaultOptions: { |
|||
queries: { |
|||
retry: false, |
|||
}, |
|||
}, |
|||
}); |
|||
|
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionDetailClient |
|||
closeLabel="Close" |
|||
continueLabel="Continue" |
|||
description="Desc" |
|||
informationLabel="Info" |
|||
itemSlug={slug} |
|||
questionsListHref="/questions-list" |
|||
title="Test" |
|||
/> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
// Intro screen might render first; if Start is available, click it to mount questions flow
|
|||
const startButton = screen.queryByText("Start"); |
|||
if (startButton) { |
|||
fireEvent.click(startButton); |
|||
} |
|||
}; |
|||
|
|||
it("should render correctly when Cattell API data is completely valid", () => { |
|||
setupTest("personality_test", { |
|||
questions: [ |
|||
{ |
|||
question_number: 1, |
|||
text: "Valid Question Cattell", |
|||
options: [ |
|||
{ id: "opt_a", label: "Opt1", value: "A" }, |
|||
{ id: "opt_b", label: "Opt2", value: "B" }, |
|||
{ id: "opt_c", label: "Opt3", value: "C" }, |
|||
], |
|||
} |
|||
] |
|||
}, null); |
|||
|
|||
// Retry UI should NOT be present
|
|||
expect(screen.queryByText("Retry")).toBeNull(); |
|||
// Question text should be visible
|
|||
expect(screen.getByText("Valid Question Cattell")).toBeDefined(); |
|||
}); |
|||
|
|||
it("should render Retry UI when Cattell API data is empty", () => { |
|||
setupTest("personality_test", { questions: [] }, null); |
|||
|
|||
expect(screen.getAllByText("No questions found for this test.")).toBeDefined(); |
|||
expect(screen.getAllByText("Retry")).toBeDefined(); |
|||
}); |
|||
|
|||
it("should render Retry UI when Cattell options are invalid (schema failure) and trigger refetch on Retry", async () => { |
|||
setupTest("personality_test", { |
|||
questions: [ |
|||
{ |
|||
question_number: 1, |
|||
text: "Invalid Question", |
|||
options: [{ label: "Opt1", value: "A" }], // Invalid schema
|
|||
} |
|||
] |
|||
}, null); |
|||
|
|||
expect(screen.getAllByText("No questions found for this test.")).toBeDefined(); |
|||
const retryBtn = screen.getAllByText("Retry")[0]; |
|||
|
|||
fireEvent.click(retryBtn); |
|||
await waitFor(() => { |
|||
expect(mockCattellRefetch).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
|
|||
it("should render correctly when Glasser API data is completely valid", () => { |
|||
setupTest("glasser_5_needs_test", null, { |
|||
questions: [ |
|||
{ |
|||
question_number: 1, |
|||
text: "Valid Question Glasser", |
|||
factor_code: "SUR", |
|||
options: [ |
|||
{ id: "o1", label: "O1", value: 1 }, |
|||
{ id: "o2", label: "O2", value: 2 }, |
|||
{ id: "o3", label: "O3", value: 3 }, |
|||
{ id: "o4", label: "O4", value: 4 }, |
|||
{ id: "o5", label: "O5", value: 5 }, |
|||
], |
|||
} |
|||
] |
|||
}); |
|||
|
|||
expect(screen.queryByText("Retry")).toBeNull(); |
|||
expect(screen.getByText("Valid Question Glasser")).toBeDefined(); |
|||
}); |
|||
|
|||
it("should render Retry UI when Glasser options are invalid (schema failure) and trigger refetch on Retry", async () => { |
|||
setupTest("glasser_5_needs_test", null, { |
|||
questions: [ |
|||
{ |
|||
question_number: 1, |
|||
text: "Invalid Question Glasser", |
|||
factor_code: "SUR", |
|||
options: [ |
|||
{ label: "O1", value: 1 }, |
|||
{ label: "O2", value: 2 }, |
|||
{ label: "O3", value: 3 }, |
|||
{ label: "O4", value: 4 }, |
|||
], |
|||
} |
|||
] |
|||
}); |
|||
expect(screen.getAllByText("No questions found for this test.")).toBeDefined(); |
|||
const retryBtn = screen.getAllByText("Retry")[0]; |
|||
|
|||
fireEvent.click(retryBtn); |
|||
await waitFor(() => { |
|||
expect(mockGlasserRefetch).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
|
|||
it("should render profile questions using ID-based data flow", () => { |
|||
(convertSchemaToFrontendItems as any).mockReturnValue([ |
|||
{ |
|||
slug: "profile_test", |
|||
title: "Profile Form", |
|||
questions: [ |
|||
{ |
|||
id: "q_123", |
|||
title: "Dynamic ID Question", |
|||
type: "text", |
|||
order: 1, |
|||
required: true, |
|||
isVisible: true, |
|||
private: false, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: {}, |
|||
options: [] |
|||
}, |
|||
{ |
|||
id: "q_456", |
|||
title: "Another ID Question", |
|||
type: "radio", |
|||
order: 2, |
|||
required: false, |
|||
isVisible: true, |
|||
private: false, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: {}, |
|||
options: [ |
|||
{ id: "opt_1", label: "Yes", value: "yes", order: 1 }, |
|||
{ id: "opt_2", label: "No", value: "no", order: 2 } |
|||
] |
|||
} |
|||
] |
|||
} |
|||
]); |
|||
|
|||
const queryClient = new QueryClient({ |
|||
defaultOptions: { queries: { retry: false } }, |
|||
}); |
|||
|
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionDetailClient |
|||
closeLabel="Close" |
|||
continueLabel="Continue" |
|||
description="Desc" |
|||
informationLabel="Info" |
|||
itemSlug="profile_test" |
|||
questionsListHref="/questions-list" |
|||
title="Profile Test" |
|||
/> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
// Profile questions render directly, no start button
|
|||
expect(screen.getByText(/Dynamic ID/)).toBeDefined(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,100 @@ |
|||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; |
|||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; |
|||
import TermsRoute from './page'; |
|||
|
|||
const mockUseMarriageProfileQuery = vi.fn(); |
|||
const mockReplace = vi.fn(); |
|||
|
|||
vi.mock('@/hooks/marriage/use-profile-main', () => ({ |
|||
useMarriageProfileQuery: () => mockUseMarriageProfileQuery(), |
|||
})); |
|||
|
|||
vi.mock('next/navigation', () => ({ |
|||
useRouter: () => ({ |
|||
replace: mockReplace, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/translations/provider', () => ({ |
|||
useI18n: () => ({ |
|||
locale: 'en', |
|||
dictionary: { |
|||
"Failed to load profile. Please try again.": "Failed to load profile. Please try again.", |
|||
"Retry": "Retry", |
|||
}, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/translations/config', () => ({ |
|||
localizePath: (path: string) => path, |
|||
})); |
|||
|
|||
vi.mock('@/components/Componentes/slider-page', () => ({ |
|||
default: () => <div data-testid="slider-page">SliderPage</div>, |
|||
})); |
|||
|
|||
describe('TermsRoute Guard', () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it('renders loading state when isLoading is true', () => { |
|||
mockUseMarriageProfileQuery.mockReturnValue({ isLoading: true }); |
|||
render(<TermsRoute />); |
|||
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
|||
// The spinner should be visible, maybe just check for a div. We can check slider is absent.
|
|||
}); |
|||
|
|||
it('renders Error/Retry when isError is true', () => { |
|||
const mockRefetch = vi.fn(); |
|||
mockUseMarriageProfileQuery.mockReturnValue({ isError: true, refetch: mockRefetch }); |
|||
render(<TermsRoute />); |
|||
|
|||
expect(screen.getByText('Failed to load profile. Please try again.')).toBeInTheDocument(); |
|||
const retryBtn = screen.getByText('Retry'); |
|||
fireEvent.click(retryBtn); |
|||
expect(mockRefetch).toHaveBeenCalled(); |
|||
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
|||
}); |
|||
|
|||
it('pending_onboarding => SliderPage renders', () => { |
|||
mockUseMarriageProfileQuery.mockReturnValue({ |
|||
isLoading: false, |
|||
data: { status: 'pending_onboarding' } |
|||
}); |
|||
render(<TermsRoute />); |
|||
|
|||
expect(screen.getByTestId('slider-page')).toBeInTheDocument(); |
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('pending_info => redirects to Questions and SliderPage is NOT rendered', async () => { |
|||
mockUseMarriageProfileQuery.mockReturnValue({ |
|||
isLoading: false, |
|||
data: { status: 'pending_info' } |
|||
}); |
|||
render(<TermsRoute />); |
|||
|
|||
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalled(); // Should redirect to /questions-list or /questions-list/personal_info
|
|||
}); |
|||
}); |
|||
|
|||
it('waiting => redirects to Finding/Waiting Page and SliderPage is NOT rendered', async () => { |
|||
mockUseMarriageProfileQuery.mockReturnValue({ |
|||
isLoading: false, |
|||
data: { status: 'waiting' } |
|||
}); |
|||
render(<TermsRoute />); |
|||
|
|||
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalledWith('/finding-match'); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -1,5 +1,64 @@ |
|||
"use client"; |
|||
|
|||
import { useEffect } from "react"; |
|||
import { useRouter } from "next/navigation"; |
|||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|||
import { getSubmitPath } from "@/lib/get-submit-path"; |
|||
import SliderPage from "@/components/Componentes/slider-page"; |
|||
import Button from "@/components/Componentes/button"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
import { localizePath } from "@/translations/config"; |
|||
|
|||
export default function TermsRoute() { |
|||
const { data: profile, isLoading, isError, refetch } = useMarriageProfileQuery(); |
|||
const router = useRouter(); |
|||
const { locale, dictionary: t } = useI18n(); |
|||
|
|||
useEffect(() => { |
|||
if (!isLoading && profile) { |
|||
if (profile.status !== "pending_onboarding") { |
|||
const target = getSubmitPath(profile); |
|||
if (target !== "/terms") { |
|||
router.replace(localizePath(target, locale)); |
|||
} |
|||
} |
|||
} |
|||
}, [profile, isLoading, router, locale]); |
|||
|
|||
if (isLoading) { |
|||
return ( |
|||
<div className="flex h-[100dvh] items-center justify-center bg-[#F5F5F5]"> |
|||
<div className="size-8 animate-spin rounded-full border-4 border-[#F14B46] border-t-transparent" /> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (isError) { |
|||
return ( |
|||
<div className="flex h-[100dvh] flex-col items-center justify-center p-4 bg-[#F5F5F5]"> |
|||
<p className="mb-4 text-center font-medium text-[#F14B46]"> |
|||
{/* @ts-expect-error - missing key */} |
|||
{t["Failed to load profile. Please try again."] || "Failed to load profile. Please try again."} |
|||
</p> |
|||
<div className="w-full max-w-[200px]"> |
|||
<Button onClick={() => refetch()}> |
|||
{/* @ts-expect-error - missing key */} |
|||
{t["Retry"] || "Retry"} |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (profile && profile.status !== "pending_onboarding") { |
|||
// If target is /terms but status is not pending_onboarding, we should still not render SliderPage
|
|||
// to prevent showing Onboarding to users who already finished it but have an unknown status.
|
|||
return ( |
|||
<div className="flex h-[100dvh] items-center justify-center bg-[#F5F5F5]"> |
|||
<div className="size-8 animate-spin rounded-full border-4 border-[#F14B46] border-t-transparent" /> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
return <SliderPage />; |
|||
} |
|||
@ -1,72 +0,0 @@ |
|||
"use client"; |
|||
|
|||
import { useQuestionAnswers } from "./question-answer-storage"; |
|||
import type { QuestionField } from "@/data/question-data"; |
|||
import QuestionRenderer from "./question-renderer"; |
|||
|
|||
type ConditionalQuestionsProps = { |
|||
parentQuestion: QuestionField; |
|||
parentQuestionIndex: number; |
|||
rules: { |
|||
values: string[]; |
|||
subQuestions: QuestionField[]; |
|||
}[]; |
|||
dobQuestion?: QuestionField; |
|||
dobQuestionIndex?: number; |
|||
}; |
|||
|
|||
export function ConditionalQuestions({ |
|||
parentQuestion, |
|||
parentQuestionIndex, |
|||
rules, |
|||
dobQuestion, |
|||
dobQuestionIndex, |
|||
}: ConditionalQuestionsProps) { |
|||
const { getAnswerValue } = useQuestionAnswers(); |
|||
const parentAnswer = getAnswerValue(parentQuestion, parentQuestionIndex); |
|||
|
|||
// Find if there is a matching rule for the current parent answer
|
|||
const matchedRule = rules.find((rule) => { |
|||
return rule.values.includes(String(parentAnswer)); |
|||
}); |
|||
|
|||
if (!matchedRule) { |
|||
return ( |
|||
<QuestionRenderer |
|||
question={parentQuestion} |
|||
questionIndex={parentQuestionIndex} |
|||
dobQuestion={dobQuestion} |
|||
dobQuestionIndex={dobQuestionIndex} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<div className="flex flex-col gap-5 w-full"> |
|||
<QuestionRenderer |
|||
question={parentQuestion} |
|||
questionIndex={parentQuestionIndex} |
|||
dobQuestion={dobQuestion} |
|||
dobQuestionIndex={dobQuestionIndex} |
|||
/> |
|||
<div className="flex flex-col gap-5 pl-4 border-l-2 border-[#F2465F]/20 mt-2 animate-in fade-in slide-in-from-top-4 duration-300"> |
|||
{matchedRule.subQuestions.map((subQuestion, index) => { |
|||
// Sub-question index is calculated uniquely to prevent conflicts
|
|||
const subIndex = parentQuestionIndex * 100 + index + 1; |
|||
return ( |
|||
<div key={subQuestion.title} className="w-full"> |
|||
<QuestionRenderer |
|||
question={subQuestion} |
|||
questionIndex={subIndex} |
|||
dobQuestion={dobQuestion} |
|||
dobQuestionIndex={dobQuestionIndex} |
|||
/> |
|||
</div> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default ConditionalQuestions; |
|||
@ -1,524 +0,0 @@ |
|||
import { |
|||
isQuestionRequiredForProfile, |
|||
isQuestionVisibleForProfile, |
|||
type QuestionListItem, |
|||
} from "@/data/question-data"; |
|||
import type { |
|||
MarriageFieldValue, |
|||
MarriageGender, |
|||
} from "@/hooks/marriage/types"; |
|||
import { hasQuestionAnswerValue } from "./question-answer-storage"; |
|||
|
|||
export function getStoredAge(): number | null { |
|||
try { |
|||
if (typeof window === "undefined") return null; |
|||
const rawValue = window.localStorage.getItem( |
|||
"marriage:sections:personal_info:answers", |
|||
); |
|||
if (!rawValue) return null; |
|||
const storedAnswers = JSON.parse(rawValue); |
|||
const ageField = storedAnswers.fields?.find( |
|||
(f: Record<string, unknown>) => |
|||
f.type === "number" || |
|||
f.label === "Age" || |
|||
f.label === "سن" || |
|||
(typeof f.key === "string" && |
|||
(f.key.endsWith("_age") || f.key.endsWith("_sn"))), |
|||
); |
|||
if (ageField && ageField.value !== undefined && ageField.value !== null) { |
|||
const num = Number(ageField.value); |
|||
if (Number.isFinite(num)) return num; |
|||
} |
|||
const dobField = storedAnswers.fields?.find( |
|||
(f: Record<string, unknown>) => |
|||
f.type === "date" || |
|||
f.label === "Date of Birth" || |
|||
f.label === "تاریخ تولد" || |
|||
(typeof f.key === "string" && |
|||
(f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld"))), |
|||
); |
|||
if (dobField?.value) { |
|||
const dob = new Date(String(dobField.value)); |
|||
if (!Number.isNaN(dob.getTime())) { |
|||
const today = new Date(); |
|||
let age = today.getFullYear() - dob.getFullYear(); |
|||
const m = today.getMonth() - dob.getMonth(); |
|||
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) { |
|||
age--; |
|||
} |
|||
return age >= 0 ? age : null; |
|||
} |
|||
} |
|||
} catch (_e) {} |
|||
return null; |
|||
} |
|||
|
|||
function isFieldAnswered( |
|||
q: Record<string, unknown>, |
|||
field: Record<string, unknown> | undefined, |
|||
): boolean { |
|||
if (!field || !hasQuestionAnswerValue(field.value as MarriageFieldValue)) { |
|||
return false; |
|||
} |
|||
if (q.type === "birthplace") { |
|||
const strVal = String(field.value); |
|||
const parts = strVal.split(",").map((p) => p.trim()); |
|||
return parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0; |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
function hashString(value: string) { |
|||
let hash = 0; |
|||
for (let index = 0; index < value.length; index += 1) { |
|||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0; |
|||
} |
|||
return hash.toString(36); |
|||
} |
|||
|
|||
function slugifyTitle(title: string) { |
|||
const slug = title |
|||
.normalize("NFKD") |
|||
.replace(/[\u0300-\u036f]/g, "") |
|||
.toLowerCase() |
|||
.replace(/[^a-z0-9]+/g, "_") |
|||
.replace(/^_+|_+$/g, ""); |
|||
return slug || `field_${hashString(title)}`; |
|||
} |
|||
|
|||
export function getLocalSectionProgress( |
|||
item: QuestionListItem, |
|||
profile: { gender?: MarriageGender | null } | null | undefined, |
|||
age: number | null, |
|||
): number | null { |
|||
try { |
|||
if (typeof window === "undefined") return null; |
|||
|
|||
if (item.slug === "personality_test" || item.slug === "glasser_5_needs_test") { |
|||
const draftKey = `marriage:tests:${item.slug}:draft`; |
|||
const draftRaw = window.localStorage.getItem(draftKey); |
|||
if (draftRaw) { |
|||
try { |
|||
const parsed = JSON.parse(draftRaw); |
|||
if (parsed && typeof parsed.answers === "object" && parsed.answers !== null) { |
|||
const answeredCount = Object.keys(parsed.answers).length; |
|||
const totalCount = item.slug === "personality_test" ? 187 : 35; |
|||
return Math.max( |
|||
0, |
|||
Math.min( |
|||
100, |
|||
Math.round((answeredCount / totalCount) * 100), |
|||
), |
|||
); |
|||
} |
|||
} catch {} |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
const fields: Record<string, unknown>[] = []; |
|||
let hasFoundStorage = false; |
|||
|
|||
const mainKey = `marriage:sections:${item.slug}:answers`; |
|||
const mainRaw = window.localStorage.getItem(mainKey); |
|||
if (mainRaw) { |
|||
try { |
|||
const parsed = JSON.parse(mainRaw); |
|||
if ( |
|||
parsed && |
|||
Array.isArray(parsed.fields) && |
|||
parsed.fields.length > 0 |
|||
) { |
|||
fields.push(...parsed.fields); |
|||
hasFoundStorage = true; |
|||
} |
|||
} catch {} |
|||
} |
|||
|
|||
if (item.slug === "family_marital_history") { |
|||
const fbRaw = window.localStorage.getItem( |
|||
"marriage:sections:family_background:answers", |
|||
); |
|||
if (fbRaw) { |
|||
try { |
|||
const parsed = JSON.parse(fbRaw); |
|||
if (parsed && Array.isArray(parsed.fields)) { |
|||
fields.push(...parsed.fields); |
|||
hasFoundStorage = true; |
|||
} |
|||
} catch {} |
|||
} |
|||
|
|||
const mhRaw = window.localStorage.getItem( |
|||
"marriage:sections:marital_history_children:answers", |
|||
); |
|||
if (mhRaw) { |
|||
try { |
|||
const parsed = JSON.parse(mhRaw); |
|||
if (parsed && Array.isArray(parsed.fields)) { |
|||
fields.push(...parsed.fields); |
|||
hasFoundStorage = true; |
|||
} |
|||
} catch {} |
|||
} |
|||
} |
|||
|
|||
if (!hasFoundStorage || fields.length === 0) { |
|||
return null; |
|||
} |
|||
|
|||
const profileContext = { |
|||
age, |
|||
gender: profile?.gender, |
|||
}; |
|||
|
|||
const hasDobQuestion = item.questions.some( |
|||
(q) => q.title === "Date of Birth" || q.title === "تاریخ تولد", |
|||
); |
|||
|
|||
const profileVisible = item.questions |
|||
.filter((question) => { |
|||
if ( |
|||
hasDobQuestion && |
|||
(question.title === "Age" || question.title === "سن") |
|||
) { |
|||
return false; |
|||
} |
|||
return isQuestionVisibleForProfile(question, profileContext); |
|||
}) |
|||
.map((question) => ({ |
|||
...question, |
|||
required: isQuestionRequiredForProfile(question, profileContext), |
|||
})); |
|||
|
|||
const getQuestionFieldKey = (question: any, questionIndex: number) => { |
|||
const index = |
|||
question.originalIndex !== undefined |
|||
? question.originalIndex |
|||
: questionIndex; |
|||
return `q${index + 1}_${slugifyTitle(question.englishTitle || question.title)}`; |
|||
}; |
|||
|
|||
const findAnswer = (question: any, questionIndex: number) => { |
|||
const key = getQuestionFieldKey(question, questionIndex); |
|||
const engSlug = slugifyTitle(question.englishTitle || question.title); |
|||
const field = fields.find( |
|||
(f) => |
|||
f && |
|||
(f.key === key || |
|||
f.label === question.title || |
|||
f.label === question.englishTitle || |
|||
(typeof f.key === "string" && |
|||
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))), |
|||
); |
|||
return field?.value; |
|||
}; |
|||
|
|||
// Find employment status question by title or by choices length (10)
|
|||
const employmentQuestionIndex = profileVisible.findIndex((q) => { |
|||
return ( |
|||
q.title === "Employment Status" || |
|||
q.title === "وضعیت اشتغال" || |
|||
(q.type === "dropdown" && q.extras?.options?.length === 10) |
|||
); |
|||
}); |
|||
|
|||
let employmentSelectedOptionIndex = -1; |
|||
if (employmentQuestionIndex !== -1) { |
|||
const employmentQuestion = profileVisible[employmentQuestionIndex]; |
|||
const ans = findAnswer(employmentQuestion, employmentQuestionIndex); |
|||
if (ans) { |
|||
employmentSelectedOptionIndex = |
|||
employmentQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|||
} |
|||
} |
|||
|
|||
// Find Parents' Survival Status question index and check selected index
|
|||
const survivalStatusQuestionIndex = profileVisible.findIndex((q) => { |
|||
return ( |
|||
q.title === "Parents' Survival Status" || |
|||
q.title === "وضعیت حیات والدین" |
|||
); |
|||
}); |
|||
|
|||
let survivalSelectedOptionIndex = -1; |
|||
if (survivalStatusQuestionIndex !== -1) { |
|||
const survivalQuestion = profileVisible[survivalStatusQuestionIndex]; |
|||
const ans = findAnswer(survivalQuestion, survivalStatusQuestionIndex); |
|||
if (ans) { |
|||
survivalSelectedOptionIndex = |
|||
survivalQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|||
} |
|||
} |
|||
|
|||
// Find Parents' Marital Status question index and check selected index
|
|||
const maritalStatusQuestionIndex = profileVisible.findIndex((q) => { |
|||
return ( |
|||
q.title === "Parents' Marital Status" || q.title === "وضعیت تأهل والدین" |
|||
); |
|||
}); |
|||
|
|||
let isCircumstancesSelected = false; |
|||
if (maritalStatusQuestionIndex !== -1) { |
|||
const maritalQuestion = profileVisible[maritalStatusQuestionIndex]; |
|||
const ans = findAnswer(maritalQuestion, maritalStatusQuestionIndex); |
|||
if (ans) { |
|||
const selectedIdx = |
|||
maritalQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|||
isCircumstancesSelected = selectedIdx === 2; |
|||
} |
|||
} |
|||
|
|||
// Find Current Marital Status question index and check selected index
|
|||
const currentMaritalQuestionIndex = profileVisible.findIndex((q) => { |
|||
return ( |
|||
q.title === "Current Marital Status" || q.title === "وضعیت تأهل فعلی" |
|||
); |
|||
}); |
|||
|
|||
let maritalSelectedIndex = -1; |
|||
if (currentMaritalQuestionIndex !== -1) { |
|||
const maritalQuestion = profileVisible[currentMaritalQuestionIndex]; |
|||
const ans = findAnswer(maritalQuestion, currentMaritalQuestionIndex); |
|||
if (ans) { |
|||
maritalSelectedIndex = |
|||
maritalQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|||
} |
|||
} |
|||
|
|||
// Find Children and Guardianship Status question index
|
|||
const custodyQuestionIndex = profileVisible.findIndex((q) => { |
|||
return ( |
|||
q.title === "Children and Guardianship Status" || |
|||
q.title === "وضعیت فرزند و تکفل" |
|||
); |
|||
}); |
|||
|
|||
let hasChildrenSelected = false; |
|||
let hasAnyGuardianshipSelected = false; |
|||
|
|||
if (custodyQuestionIndex !== -1) { |
|||
const custodyQuestion = profileVisible[custodyQuestionIndex]; |
|||
const ans = findAnswer(custodyQuestion, custodyQuestionIndex); |
|||
if (ans) { |
|||
const ansList = Array.isArray(ans) ? ans.map(String) : [String(ans)]; |
|||
hasChildrenSelected = ansList.some( |
|||
(val) => val.includes("Have children") || val.includes("فرزند دارم"), |
|||
); |
|||
hasAnyGuardianshipSelected = ansList.some( |
|||
(val) => |
|||
val.includes("Have children") || |
|||
val.includes("فرزند دارم") || |
|||
val.includes("under my guardianship") || |
|||
val.includes("تحت تکفل"), |
|||
); |
|||
} |
|||
} |
|||
|
|||
const filtered = profileVisible.filter((question, index) => { |
|||
// Check if this is one of the marital status/children questions
|
|||
if (currentMaritalQuestionIndex !== -1) { |
|||
const isDuration = |
|||
index === currentMaritalQuestionIndex + 1 || |
|||
question.title === "Previous Marriage Duration" || |
|||
question.title === "مدت ازدواج یا عقد قبلی"; |
|||
|
|||
const isSeparation = |
|||
index === currentMaritalQuestionIndex + 2 || |
|||
question.title === "Reason for Separation" || |
|||
question.title === "علت جدایی، در صورت وجود"; |
|||
|
|||
const isCustody = |
|||
index === currentMaritalQuestionIndex + 3 || |
|||
question.title === "Children and Guardianship Status" || |
|||
question.title === "وضعیت فرزند و تکفل"; |
|||
|
|||
const isChildrenCount = |
|||
index === currentMaritalQuestionIndex + 4 || |
|||
question.title === "Number of Children" || |
|||
question.title === "تعداد فرزندان"; |
|||
|
|||
const isChildrenExplanation = |
|||
index === currentMaritalQuestionIndex + 5 || |
|||
question.title === "Short Children/Guardianship Explanation" || |
|||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل"; |
|||
|
|||
if (isDuration) { |
|||
return [1, 2, 3].includes(maritalSelectedIndex); |
|||
} |
|||
if (isSeparation) { |
|||
return [1, 2].includes(maritalSelectedIndex); |
|||
} |
|||
if (isCustody) { |
|||
return [2, 3].includes(maritalSelectedIndex); |
|||
} |
|||
if (isChildrenCount) { |
|||
return [2, 3].includes(maritalSelectedIndex) && hasChildrenSelected; |
|||
} |
|||
if (isChildrenExplanation) { |
|||
return ( |
|||
[2, 3].includes(maritalSelectedIndex) && hasAnyGuardianshipSelected |
|||
); |
|||
} |
|||
} |
|||
|
|||
// Check if this is one of the three job-related questions
|
|||
if (employmentQuestionIndex !== -1) { |
|||
const isJobTitle = |
|||
index === employmentQuestionIndex + 1 || |
|||
question.title === "Job Title" || |
|||
question.title === "عنوان شغلی"; |
|||
|
|||
const isWorkLocation = |
|||
index === employmentQuestionIndex + 2 || |
|||
question.title === "Work Location" || |
|||
question.title === "محل فعالیت"; |
|||
|
|||
const isMonthlyIncome = |
|||
index === employmentQuestionIndex + 3 || |
|||
question.title === "Monthly Income" || |
|||
question.title === "میزان درآمد ماهانه"; |
|||
|
|||
if (isJobTitle || isWorkLocation || isMonthlyIncome) { |
|||
// If no employment status is selected yet, hide them by default
|
|||
if (employmentSelectedOptionIndex === -1) { |
|||
return false; |
|||
} |
|||
|
|||
// Options logic:
|
|||
// Show all 3 for: index 0 (Full-time), 1 (Part-time), 2 (Self-employed), 3 (Entrepreneur), 5 (Working Student)
|
|||
const showAll = [0, 1, 2, 3, 5].includes( |
|||
employmentSelectedOptionIndex, |
|||
); |
|||
// Hide all 3 for: index 4 (Student), 6 (Student & Job Seeking), 7 (Job Seeking / Unemployed), 8 (Homemaker)
|
|||
const hideAll = [4, 6, 7, 8].includes(employmentSelectedOptionIndex); |
|||
// Special Retired logic: index 9 (Retired)
|
|||
const isRetired = employmentSelectedOptionIndex === 9; |
|||
|
|||
if (showAll) { |
|||
return true; |
|||
} |
|||
if (hideAll) { |
|||
return false; |
|||
} |
|||
if (isRetired) { |
|||
if (isJobTitle || isMonthlyIncome) { |
|||
return true; |
|||
} |
|||
if (isWorkLocation) { |
|||
return false; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// Check Parents' Survival Status to decide if Parents' Marital Status is visible
|
|||
if (survivalStatusQuestionIndex !== -1) { |
|||
const isParentsMaritalStatus = |
|||
question.title === "Parents' Marital Status" || |
|||
question.title === "وضعیت تأهل والدین"; |
|||
|
|||
if (isParentsMaritalStatus) { |
|||
return survivalSelectedOptionIndex === 0; |
|||
} |
|||
} |
|||
|
|||
// Default dependsOn logic
|
|||
if (question.logic?.dependsOn) { |
|||
const { title, values } = question.logic.dependsOn; |
|||
const dependentQuestionIndex = profileVisible.findIndex( |
|||
(q) => q.title === title, |
|||
); |
|||
|
|||
if (dependentQuestionIndex !== -1) { |
|||
const dependentQuestion = profileVisible[dependentQuestionIndex]; |
|||
const answer = findAnswer(dependentQuestion, dependentQuestionIndex); |
|||
if (Array.isArray(answer)) { |
|||
return answer.some((ans) => values.includes(String(ans))); |
|||
} |
|||
return values.includes(String(answer)); |
|||
} |
|||
return false; |
|||
} |
|||
return true; |
|||
}); |
|||
|
|||
const activeQuestions = filtered.map((question) => { |
|||
if ( |
|||
question.title === "Short Family Description" || |
|||
question.title === "توضیح کوتاه درباره خانواده" |
|||
) { |
|||
return { |
|||
...question, |
|||
required: isCircumstancesSelected, |
|||
}; |
|||
} |
|||
|
|||
if ( |
|||
question.title === "Previous Marriage Duration" || |
|||
question.title === "مدت ازدواج یا عقد قبلی" || |
|||
question.title === "Number of Children" || |
|||
question.title === "تعداد فرزندان" || |
|||
question.title === "Short Children/Guardianship Explanation" || |
|||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل" || |
|||
question.title === "Additional details about family responsibility" || |
|||
question.title === "توضیحات تکمیلی درباره مسئولیت خانوادگی" || |
|||
question.title === "Do the supported individual(s) live with you?" || |
|||
question.title === "آیا فرد یا افراد تحت حمایت با شما زندگی میکنند؟" || |
|||
question.title === "What is the custody status of your child(ren)?" || |
|||
question.title === "وضعیت حضانت فرزند یا فرزندان شما چگونه است؟" || |
|||
question.title === |
|||
"Does the custody, visitation, or relocation schedule impact your residence or immigration?" || |
|||
question.title === |
|||
"آیا برنامه حضانت، ملاقات یا جابهجایی فرزند بر محل زندگی یا امکان مهاجرت شما تأثیر میگذارد؟" || |
|||
question.title === |
|||
"What is the payment or receipt status of child support?" || |
|||
question.title === |
|||
"وضعیت پرداخت یا دریافت نفقه و حمایت مالی فرزند چگونه است؟" || |
|||
question.title === |
|||
"Acceptance of necessary communication between future spouse and the other parent" || |
|||
question.title === "پذیرش ارتباط ضروری همسر آینده با والد دیگرِ فرزند" || |
|||
question.title === "پذیرش ارتباط ضروری همسر آینده با والد دیگر فرزند" |
|||
) { |
|||
return { |
|||
...question, |
|||
required: true, |
|||
}; |
|||
} |
|||
return question; |
|||
}); |
|||
|
|||
const requiredQuestions = activeQuestions.filter((q) => q.required); |
|||
|
|||
if (requiredQuestions.length === 0) { |
|||
return 100; |
|||
} |
|||
|
|||
const answeredCount = requiredQuestions.filter((q) => { |
|||
const idx = profileVisible.indexOf(q); |
|||
const key = getQuestionFieldKey(q, idx); |
|||
const engSlug = slugifyTitle(q.englishTitle || q.title); |
|||
const field = fields.find( |
|||
(f) => |
|||
f && |
|||
(f.key === key || |
|||
f.label === q.title || |
|||
f.label === q.englishTitle || |
|||
(typeof f.key === "string" && |
|||
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))), |
|||
); |
|||
return isFieldAnswered(q, field); |
|||
}).length; |
|||
|
|||
return Math.max( |
|||
0, |
|||
Math.min( |
|||
100, |
|||
Math.round((answeredCount / requiredQuestions.length) * 100), |
|||
), |
|||
); |
|||
} catch (_e) { |
|||
return null; |
|||
} |
|||
} |
|||
@ -0,0 +1,260 @@ |
|||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; |
|||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; |
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage"; |
|||
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; |
|||
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; |
|||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|||
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; |
|||
|
|||
vi.mock("@/hooks/marriage/use-form-schema", () => ({ |
|||
useFormSchemaQuery: vi.fn(), |
|||
})); |
|||
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
|||
useMarriageProfileQuery: vi.fn(), |
|||
})); |
|||
vi.mock("@/hooks/marriage/use-section-data", () => ({ |
|||
useMarriageSectionDataQuery: vi.fn(), |
|||
useUpdateMarriageSectionDataMutation: vi.fn(), |
|||
})); |
|||
|
|||
// Dummy component to interact with the context
|
|||
function TestComponent({ slug }: { slug: string }) { |
|||
const { setAnswerValue, flushAnswers } = useQuestionAnswers(); |
|||
|
|||
return ( |
|||
<div> |
|||
<button |
|||
data-testid="set-radio" |
|||
onClick={() => |
|||
setAnswerValue( |
|||
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] }, |
|||
"opt1" |
|||
) |
|||
} |
|||
> |
|||
Set Radio |
|||
</button> |
|||
|
|||
<button |
|||
data-testid="set-checkbox" |
|||
onClick={() => |
|||
setAnswerValue( |
|||
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] }, |
|||
["opt2", "opt3"] |
|||
) |
|||
} |
|||
> |
|||
Set Checkbox |
|||
</button> |
|||
|
|||
<button data-testid="save" onClick={() => flushAnswers()}> |
|||
Save |
|||
</button> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
describe("Question Answer & Schema Integration", () => { |
|||
let capturedPayload: any = null; |
|||
|
|||
beforeEach(() => { |
|||
capturedPayload = null; |
|||
const updateMutateAsync = vi.fn(async (payload) => { |
|||
capturedPayload = payload; |
|||
return payload; |
|||
}); |
|||
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({ |
|||
mutateAsync: updateMutateAsync, |
|||
isPending: false, |
|||
}); |
|||
(useMarriageProfileQuery as any).mockReturnValue({ |
|||
data: { can_edit_profile: true }, |
|||
}); |
|||
(useMarriageSectionDataQuery as any).mockReturnValue({ |
|||
data: [], |
|||
isLoading: false, |
|||
}); |
|||
(useFormSchemaQuery as any).mockReturnValue({ |
|||
data: { is_completed: false }, |
|||
isLoading: false, |
|||
isFetching: false, |
|||
}); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it("should send option_id instead of label/value for radio", async () => { |
|||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
|||
|
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider |
|||
slug="test_slug" |
|||
questions={[ |
|||
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] }, |
|||
]} |
|||
> |
|||
<TestComponent slug="test_slug" /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
fireEvent.click(screen.getByTestId("set-radio")); |
|||
fireEvent.click(screen.getByTestId("save")); |
|||
|
|||
await waitFor(() => { |
|||
expect(capturedPayload).not.toBeNull(); |
|||
const field = capturedPayload.fields.find((f: any) => f.key === "q1"); |
|||
expect(field.option_id).toBe("opt1"); |
|||
expect(field.value).toBe("opt1"); // ui value is option_id
|
|||
}); |
|||
}); |
|||
|
|||
it("should send array of option_ids for checkbox", async () => { |
|||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
|||
|
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider |
|||
slug="test_slug" |
|||
questions={[ |
|||
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] }, |
|||
]} |
|||
> |
|||
<TestComponent slug="test_slug" /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
fireEvent.click(screen.getByTestId("set-checkbox")); |
|||
fireEvent.click(screen.getByTestId("save")); |
|||
|
|||
await waitFor(() => { |
|||
expect(capturedPayload).not.toBeNull(); |
|||
const field = capturedPayload.fields.find((f: any) => f.key === "q2"); |
|||
expect(field.option_id).toEqual(["opt2", "opt3"]); |
|||
}); |
|||
}); |
|||
|
|||
it("should sort schema questions and options by order correctly", () => { |
|||
const mockSchema = { |
|||
sections: [ |
|||
{ |
|||
id: "sec1", |
|||
title: "Section", |
|||
order: 1, |
|||
icon: "user-circle", |
|||
is_required: true, |
|||
estimated_minutes: 5, |
|||
cards: [ |
|||
{ |
|||
id: "card1", |
|||
title: "Card", |
|||
order: 2, |
|||
questions: [ |
|||
{ id: "q1", title: "Q1", type: "text", order: 10, required: true, is_visible: true, ui_config: {}, options: [] }, |
|||
{ id: "q2", title: "Q2", type: "text", order: 5, required: true, is_visible: true, ui_config: {}, options: [ |
|||
{ id: "opt1", value: "A", label: "Option A", order: 2 }, |
|||
{ id: "opt2", value: "B", label: "Option B", order: 1 } |
|||
] }, |
|||
] |
|||
}, |
|||
{ |
|||
id: "card2", |
|||
title: "Card 2", |
|||
order: 1, |
|||
questions: [ |
|||
{ id: "q3", title: "Q3", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] }, |
|||
] |
|||
} |
|||
] |
|||
} |
|||
], |
|||
progress: { sections_progress: {} } |
|||
} as any; |
|||
|
|||
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); |
|||
const questions = frontendItems[0].questions; |
|||
|
|||
expect(questions[0].id).toBe("q3"); |
|||
expect(questions[1].id).toBe("q2"); |
|||
expect(questions[2].id).toBe("q1"); |
|||
|
|||
expect(questions[1].options[0].id).toBe("opt2"); |
|||
expect(questions[1].options[1].id).toBe("opt1"); |
|||
}); |
|||
|
|||
it("should hydrate radio/dropdown/checkbox with option_id instead of canonical value", async () => { |
|||
// Mock the backend sending canonical value but keeping option_id
|
|||
(useMarriageSectionDataQuery as any).mockReturnValue({ |
|||
data: { |
|||
slug: "test_slug", |
|||
data: [ |
|||
{ key: "q_radio", type: "radio", value: "Server Canonical Value", option_id: "opt_radio" }, |
|||
{ key: "q_check", type: "checkbox", value: ["Server Val 1", "Server Val 2"], option_id: ["opt_check1", "opt_check2"] }, |
|||
], |
|||
}, |
|||
isLoading: false, |
|||
}); |
|||
|
|||
let capturedValues: any = {}; |
|||
|
|||
function HydrationTestComponent() { |
|||
const { getAnswerValue } = useQuestionAnswers(); |
|||
capturedValues.radio = getAnswerValue({ id: "q_radio" } as any); |
|||
capturedValues.check = getAnswerValue({ id: "q_check" } as any); |
|||
return <div />; |
|||
} |
|||
|
|||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
|||
|
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider slug="test_slug" questions={[]}> |
|||
<HydrationTestComponent /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
// Give it a moment to reconcile useEffect in QuestionAnswersProvider
|
|||
await waitFor(() => { |
|||
expect(capturedValues.radio).toBe("opt_radio"); |
|||
expect(capturedValues.check).toEqual(["opt_check1", "opt_check2"]); |
|||
}); |
|||
}); |
|||
|
|||
it("should respect bq.is_required over bq.required for required state", () => { |
|||
const mockSchema = { |
|||
sections: [ |
|||
{ |
|||
id: "sec1", |
|||
title: "Section", |
|||
order: 1, |
|||
icon: "user-circle", |
|||
is_required: true, |
|||
estimated_minutes: 5, |
|||
cards: [ |
|||
{ |
|||
id: "card1", |
|||
title: "Card", |
|||
order: 1, |
|||
questions: [ |
|||
{ id: "q1", title: "Q1", type: "text", order: 1, required: false, is_required: true, is_visible: true, ui_config: {}, options: [] }, |
|||
] |
|||
} |
|||
] |
|||
} |
|||
], |
|||
progress: { sections_progress: {} } |
|||
} as any; |
|||
|
|||
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); |
|||
const question = frontendItems[0].questions[0]; |
|||
|
|||
expect(question.baseRequired).toBe(false); |
|||
expect(question.required).toBe(true); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,235 @@ |
|||
import React from "react"; |
|||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; |
|||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; |
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage"; |
|||
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; |
|||
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; |
|||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|||
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; |
|||
import { QuestionRadio } from "./question-radio"; |
|||
import { QuestionCheckbox } from "./question-checkbox"; |
|||
|
|||
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client"; |
|||
|
|||
vi.mock("@/hooks/marriage/use-form-schema", () => ({ |
|||
useFormSchemaQuery: vi.fn(), |
|||
})); |
|||
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
|||
useMarriageProfileQuery: vi.fn(), |
|||
})); |
|||
vi.mock("@/hooks/marriage/use-section-data", () => ({ |
|||
useMarriageSectionDataQuery: vi.fn(), |
|||
useUpdateMarriageSectionDataMutation: vi.fn(), |
|||
})); |
|||
|
|||
export const replaceMock = vi.fn(); |
|||
vi.mock("next/navigation", () => ({ |
|||
useRouter: () => ({ |
|||
push: vi.fn(), |
|||
replace: (...args: any[]) => replaceMock(...args), |
|||
back: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
const mockSchema = { |
|||
form_id: "profile", |
|||
version: 1, |
|||
answers: { |
|||
"q_radio": { value: "A", option_id: "opt_a" }, |
|||
"q_check": { value: ["B", "C"], option_id: ["opt_b", "opt_c"] } |
|||
}, |
|||
sections: [ |
|||
{ |
|||
id: "sec1", |
|||
title: "Section", |
|||
order: 1, |
|||
icon: "user-circle", |
|||
is_required: true, |
|||
estimated_minutes: 5, |
|||
cards: [ |
|||
{ |
|||
id: "card1", |
|||
title: "Card", |
|||
order: 2, // Messed up order
|
|||
questions: [ |
|||
{ |
|||
id: "q_child", title: "Child Q", type: "text", order: 20, |
|||
required: false, is_required: true, is_visible: false, |
|||
ui_config: {}, options: [] |
|||
}, |
|||
{ |
|||
id: "q_radio", title: "Radio Q", type: "radio", order: 5, |
|||
required: true, is_required: true, is_visible: true, |
|||
ui_config: {}, |
|||
options: [ |
|||
{ id: "opt_a_dummy", value: "A_DUMMY", label: "Option A Dummy", order: 2 }, |
|||
{ id: "opt_a", value: "A", label: "Option A Canonical", order: 1 } |
|||
] |
|||
}, |
|||
{ |
|||
id: "q_check", title: "Check Q", type: "checkbox", order: 10, |
|||
required: false, is_required: false, is_visible: true, |
|||
ui_config: {}, |
|||
options: [ |
|||
{ id: "opt_c", value: "C", label: "Option C", order: 2 }, |
|||
{ id: "opt_b", value: "B", label: "Option B", order: 1 } |
|||
] |
|||
} |
|||
] |
|||
}, |
|||
{ |
|||
id: "card2", |
|||
title: "Card 2", |
|||
order: 1, // Card 2 should come first
|
|||
questions: [ |
|||
{ id: "q_first", title: "First Q", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] }, |
|||
] |
|||
} |
|||
] |
|||
} |
|||
], |
|||
progress: { sections_progress: {} } |
|||
}; |
|||
|
|||
describe("Schema Question Flow Integration", () => { |
|||
let capturedPayload: any = null; |
|||
|
|||
beforeEach(() => { |
|||
capturedPayload = null; |
|||
const updateMutateAsync = vi.fn(async (payload) => { |
|||
capturedPayload = payload; |
|||
return payload; |
|||
}); |
|||
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({ |
|||
mutateAsync: updateMutateAsync, |
|||
isPending: false, |
|||
}); |
|||
(useMarriageProfileQuery as any).mockReturnValue({ |
|||
data: { can_edit_profile: true }, |
|||
}); |
|||
(useMarriageSectionDataQuery as any).mockReturnValue({ |
|||
data: { |
|||
slug: "sec1", |
|||
data: [ |
|||
{ key: "q_radio", type: "radio", value: "A", option_id: "opt_a" }, |
|||
{ key: "q_check", type: "checkbox", value: ["B", "C"], option_id: ["opt_b", "opt_c"] } |
|||
] |
|||
}, |
|||
isLoading: false, |
|||
}); |
|||
(useFormSchemaQuery as any).mockReturnValue({ |
|||
data: mockSchema, |
|||
isLoading: false, |
|||
isFetching: false, |
|||
error: null, |
|||
refetch: vi.fn(), |
|||
}); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it("should enforce ordering, correctly hydrate canonical values via options, and hide invisible children", async () => { |
|||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
|||
|
|||
// Use convertSchemaToFrontendItems directly (no mock)
|
|||
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); |
|||
|
|||
// Sort logic validation
|
|||
expect(frontendItems[0].questions[0].id).toBe("q_first"); |
|||
expect(frontendItems[0].questions[1].id).toBe("q_radio"); |
|||
expect(frontendItems[0].questions[2].id).toBe("q_check"); |
|||
expect(frontendItems[0].questions[3].id).toBe("q_child"); |
|||
|
|||
// Validate options sort
|
|||
expect(frontendItems[0].questions[1].options[0].id).toBe("opt_a"); |
|||
|
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionDetailClient |
|||
itemSlug="sec1" |
|||
closeLabel="Close" |
|||
continueLabel="Continue" |
|||
description="Desc" |
|||
informationLabel="Info" |
|||
questionsListHref="/list" |
|||
title="Title" |
|||
/> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
// Wait for hydration and rendering
|
|||
await waitFor(() => { |
|||
// Radio hydration
|
|||
const radioInput = screen.getByLabelText("Option A Canonical") as HTMLInputElement; |
|||
expect(radioInput.checked).toBe(true); |
|||
|
|||
// Checkbox hydration
|
|||
const checkB = screen.getByLabelText("Option B") as HTMLInputElement; |
|||
const checkC = screen.getByLabelText("Option C") as HTMLInputElement; |
|||
expect(checkB.checked).toBe(true); |
|||
expect(checkC.checked).toBe(true); |
|||
|
|||
// Child should not be rendered
|
|||
expect(screen.queryByText("Child Q")).toBeNull(); |
|||
|
|||
// q_child is required=false but is_required=true, if it was visible it should show *.
|
|||
// Let's modify schema dynamically to test child visibility and requirement
|
|||
}); |
|||
|
|||
// Interact to fire payload
|
|||
const radioDummy = screen.getByLabelText("Option A Dummy") as HTMLInputElement; |
|||
fireEvent.click(radioDummy); |
|||
|
|||
// Uncheck Option B and Check Option C (Wait, they are both checked by default from hydration)
|
|||
const checkB = screen.getByLabelText("Option B") as HTMLInputElement; |
|||
fireEvent.click(checkB); // Should now be unchecked
|
|||
|
|||
const submitBtn = screen.getByText("Continue"); // Form submit button
|
|||
fireEvent.click(submitBtn); |
|||
|
|||
await waitFor(() => { |
|||
expect(capturedPayload).not.toBeNull(); |
|||
const radioPayload = capturedPayload.fields.find((f: any) => f.key === "q_radio"); |
|||
expect(radioPayload.option_id).toBe("opt_a_dummy"); |
|||
|
|||
const checkPayload = capturedPayload.fields.find((f: any) => f.key === "q_check"); |
|||
expect(checkPayload.option_id).toEqual(["opt_c"]); // Only C is checked now
|
|||
}); |
|||
}); |
|||
|
|||
it("should redirect and not render static questions on schema failure", () => { |
|||
replaceMock.mockClear(); |
|||
|
|||
(useFormSchemaQuery as any).mockReturnValue({ |
|||
data: undefined, |
|||
isLoading: false, |
|||
isFetching: false, |
|||
error: new Error("Network Error"), |
|||
refetch: vi.fn(), |
|||
}); |
|||
|
|||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
|||
|
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionDetailClient |
|||
itemSlug="sec1" |
|||
closeLabel="Close" |
|||
continueLabel="Continue" |
|||
description="Desc" |
|||
informationLabel="Info" |
|||
questionsListHref="/list" |
|||
title="Title" |
|||
/> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
// Should redirect to questions list
|
|||
expect(replaceMock).toHaveBeenCalledWith("/list"); |
|||
// Static text should not exist
|
|||
expect(screen.queryByText("First Q")).toBeNull(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,265 @@ |
|||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; |
|||
import userEvent from '@testing-library/user-event'; |
|||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; |
|||
import SliderPage from './slider-page'; |
|||
|
|||
const mockMutateAsync = vi.fn(); |
|||
const mockGetMarriageProfile = vi.fn(); |
|||
const mockSetQueryData = vi.fn(); |
|||
const mockReplace = vi.fn(); |
|||
const mockRemoveItem = vi.spyOn(Storage.prototype, 'removeItem'); |
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; |
|||
|
|||
vi.mock('@/hooks/marriage/use-profile-basic', () => ({ |
|||
useUpdateMarriageProfileBasicMutation: () => ({ |
|||
mutateAsync: mockMutateAsync, |
|||
isPending: false, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/hooks/marriage/use-profile-main', () => ({ |
|||
getMarriageProfile: (...args: any[]) => mockGetMarriageProfile(...args), |
|||
useMarriageProfileQuery: () => ({ data: undefined, refetch: vi.fn() }), |
|||
})); |
|||
|
|||
vi.mock('@/hooks/marriage/query-keys', () => ({ |
|||
marriageQueryKeys: { |
|||
profile: () => ['marriage', 'profile'], |
|||
}, |
|||
})); |
|||
|
|||
vi.mock('next/navigation', () => ({ |
|||
useRouter: () => ({ |
|||
replace: mockReplace, |
|||
back: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/translations/provider', () => ({ |
|||
useI18n: () => ({ |
|||
locale: 'en', |
|||
dictionary: { |
|||
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.", |
|||
"Accept & Continue": "Accept & Continue", |
|||
}, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/translations/config', () => ({ |
|||
localizePath: (path: string, locale: string) => path, |
|||
})); |
|||
|
|||
describe('SliderPage', () => { |
|||
let queryClient: QueryClient; |
|||
|
|||
beforeEach(() => { |
|||
vi.resetAllMocks(); |
|||
queryClient = new QueryClient(); |
|||
vi.spyOn(queryClient, 'setQueryData'); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.useRealTimers(); |
|||
cleanup(); |
|||
}); |
|||
|
|||
const navigateToFinalSlide = async () => { |
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<SliderPage /> |
|||
</QueryClientProvider> |
|||
); |
|||
const dots = screen.getAllByRole('button', { name: /go to slide/i }); |
|||
fireEvent.click(dots[4]); // Go directly to slide 5
|
|||
return await screen.findByText('Finish'); |
|||
}; |
|||
|
|||
it('a. Success: PATCH ok, GET ok -> replace', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
const initialRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
|
|||
let resolveGet: any; |
|||
|
|||
mockMutateAsync.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
mockGetMarriageProfile.mockReturnValueOnce(new Promise(resolve => { |
|||
resolveGet = resolve; |
|||
})); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
// Wait for PATCH to resolve and GET to be called
|
|||
await waitFor(() => { |
|||
expect(mockMutateAsync).toHaveBeenCalled(); |
|||
expect(mockGetMarriageProfile).toHaveBeenCalled(); |
|||
}); |
|||
|
|||
// Assert navigation and localStorage clear did not happen before GET resolves
|
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
const currentRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
expect(currentRemoveCount).toBe(initialRemoveCount); |
|||
|
|||
// Resolve GET
|
|||
resolveGet({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
await waitFor(() => { |
|||
expect(queryClient.setQueryData).toHaveBeenCalled(); |
|||
const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
expect(finalRemoveCount).toBe(initialRemoveCount + 1); |
|||
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); |
|||
}); |
|||
}); |
|||
|
|||
it('b. PATCH failure: no nav, Error UI, Retry works', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
|
|||
const initialRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
|
|||
mockMutateAsync.mockRejectedValueOnce(new Error('Network Error')); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); |
|||
}); |
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
|
|||
const currentRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
expect(currentRemoveCount).toBe(initialRemoveCount); |
|||
|
|||
// Retry succeeds
|
|||
mockMutateAsync.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
const retryBtn = screen.getByText('Finish'); |
|||
fireEvent.click(retryBtn); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); |
|||
}); |
|||
|
|||
const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
expect(finalRemoveCount).toBe(initialRemoveCount + 1); |
|||
}); |
|||
|
|||
it('c. Slow request: Promise pending -> no redirect', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] }); |
|||
|
|||
let resolvePatch: any; |
|||
mockMutateAsync.mockReturnValueOnce(new Promise(resolve => { |
|||
resolvePatch = resolve; |
|||
})); |
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
await vi.advanceTimersByTimeAsync(6000); |
|||
|
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
|
|||
resolvePatch({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
vi.useRealTimers(); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); |
|||
}); |
|||
}); |
|||
|
|||
it('d. Double-check failure: GET returns pending_onboarding -> Error UI', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
|
|||
mockMutateAsync.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'pending_onboarding', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); |
|||
}); |
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('e. Double click: only 1 request', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
|
|||
let resolvePatch: any; |
|||
mockMutateAsync.mockReturnValueOnce(new Promise(resolve => { |
|||
resolvePatch = resolve; |
|||
})); |
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
fireEvent.click(finishBtn); |
|||
fireEvent.click(finishBtn); |
|||
|
|||
expect(mockMutateAsync).toHaveBeenCalledTimes(1); |
|||
|
|||
resolvePatch({ status: 'pending_info', gender: 'female', is_registering_for_self: true }); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
|
|||
it('f. Double-check failure: GET returns waiting -> Error UI', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
|
|||
mockMutateAsync.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'waiting', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); |
|||
}); |
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,114 @@ |
|||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; |
|||
import { describe, it, expect, vi, afterEach } from "vitest"; |
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
import { QuestionAnswersProvider } from "./question-answer-storage"; |
|||
import { QuestionBirthplace } from "./question-birthplace"; |
|||
import QuestionNumber from "./question-number"; |
|||
import QuestionText from "./question-text"; |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: vi.fn(() => ({ locale: "fa", dictionary: {} })), |
|||
})); |
|||
|
|||
describe("UI Config based behavior", () => { |
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
|||
|
|||
it("should trigger GeoIP only when ui_config.enable_geoip is true", () => { |
|||
// With totally random title but ui_config.enable_geoip = true
|
|||
const qWithGeo = { id: "q1", title: "Random Title Here", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: { enable_geoip: true } } as any; |
|||
|
|||
const { rerender } = render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider slug="test" questions={[qWithGeo]}> |
|||
<QuestionBirthplace question={qWithGeo} /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
// Auto button is present when GeoIP is active
|
|||
expect(screen.getByText("خودکار")).toBeDefined(); |
|||
|
|||
// With title "residence" but no ui_config
|
|||
const qWithoutGeo = { id: "q2", title: "residence test", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: {} } as any; |
|||
|
|||
rerender( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider slug="test" questions={[qWithoutGeo]}> |
|||
<QuestionBirthplace question={qWithoutGeo} /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
expect(screen.queryByText("خودکار")).toBeNull(); |
|||
}); |
|||
|
|||
it("should trigger currency behavior only when ui_config.currency_enabled is true", () => { |
|||
// Title is random, but currency_enabled is true
|
|||
const qWithCurrency = { id: "q1", title: "Random Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: { currency_enabled: true } } as any; |
|||
|
|||
const { rerender } = render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider slug="test" questions={[qWithCurrency]}> |
|||
<QuestionNumber question={qWithCurrency} /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
// Dropdown arrow should be rendered in currency mode (path is in SVG)
|
|||
expect(screen.getByRole("img", { name: "Dropdown chevron" })).toBeDefined(); |
|||
|
|||
// Title is "Monthly Income", but currency_enabled is false
|
|||
const qWithoutCurrency = { id: "q2", title: "Monthly Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: {} } as any; |
|||
|
|||
rerender( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider slug="test" questions={[qWithoutCurrency]}> |
|||
<QuestionNumber question={qWithoutCurrency} /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
expect(screen.queryByRole("img", { name: "Dropdown chevron" })).toBeNull(); |
|||
}); |
|||
|
|||
it("should render error message from validation.errorMessage or fallback to locale", async () => { |
|||
// Title is random, type is email, custom error message
|
|||
const qCustomError = { id: "q1", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: { errorMessage: "Custom Backend Error" } } as any; |
|||
|
|||
const { rerender } = render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider slug="test" questions={[qCustomError]}> |
|||
<QuestionText question={qCustomError} /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
const input = screen.getByRole("textbox"); |
|||
fireEvent.change(input, { target: { value: 'invalid_email' } }); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByText("Custom Backend Error")).toBeDefined(); |
|||
}); |
|||
|
|||
const qFallbackError = { id: "q2", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: {} } as any; |
|||
|
|||
rerender( |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider slug="test" questions={[qFallbackError]}> |
|||
<QuestionText question={qFallbackError} /> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
|
|||
const input2 = screen.getByRole("textbox"); |
|||
fireEvent.change(input2, { target: { value: 'invalid_email_again' } }); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByText("یک آدرس ایمیل معتبر وارد کنید.")).toBeDefined(); |
|||
}); |
|||
}); |
|||
}); |
|||
1067
src/data/cattell-fallback.ts
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -1,183 +0,0 @@ |
|||
export type GlasserFallbackQuestion = { |
|||
question_number: number; |
|||
factor_code: string; |
|||
text: string; |
|||
}; |
|||
|
|||
export const glasserFallbackQuestions: GlasserFallbackQuestion[] = [ |
|||
{ |
|||
question_number: 1, |
|||
factor_code: "S", |
|||
text: "مسایلی مثل پس انداز، مخارج زندگی، مسکن، آینده شغلی و.. تا چه اندازه ذهن شما را به خود مشغول می دارد؟", |
|||
}, |
|||
{ |
|||
question_number: 2, |
|||
factor_code: "S", |
|||
text: "تا چه اندازه به سلامت جسمانی، بهداشت و احتمال ابتلا به بیماری فکر میکنید؟", |
|||
}, |
|||
{ |
|||
question_number: 3, |
|||
factor_code: "S", |
|||
text: "شدت میل جنسی خود را چگونه ارزیابی میکنید؟", |
|||
}, |
|||
{ |
|||
question_number: 4, |
|||
factor_code: "S", |
|||
text: "در انجام کارها و اقدامات مخاطره انگیز تا چه اندازه محتاطانه عمل میکنید؟", |
|||
}, |
|||
{ |
|||
question_number: 5, |
|||
factor_code: "S", |
|||
text: "از روبه رویی با تجارب جدید و شروع راه های ناشناخته تا چه اندازه اجتناب میکنید؟", |
|||
}, |
|||
{ |
|||
question_number: 6, |
|||
factor_code: "S", |
|||
text: "از نظر دوستان و همکاران خود چقدر وقت شناس با نظم و ترتیب و دقیق هستید؟", |
|||
}, |
|||
{ |
|||
question_number: 7, |
|||
factor_code: "S", |
|||
text: "تا چه اندازه امنیت شغلی و درآمد ثابت برایتان اهمیت دارد؟", |
|||
}, |
|||
{ |
|||
question_number: 8, |
|||
factor_code: "L", |
|||
text: "احساس می کنید به چه میزان عشق، صمیمیت و مهرورزی نیاز دارید؟", |
|||
}, |
|||
{ |
|||
question_number: 9, |
|||
factor_code: "L", |
|||
text: "تا چه اندازه رفاه و سعادت انسان های دیگر برایتان مهم است؟", |
|||
}, |
|||
{ |
|||
question_number: 10, |
|||
factor_code: "L", |
|||
text: "تا چه اندازه تمایل دارید تجارب شخصی، احساسات عمیق و اسرار خود را با دیگران در میان بگذارید؟", |
|||
}, |
|||
{ |
|||
question_number: 11, |
|||
factor_code: "L", |
|||
text: "در ایجاد صمیمیت و برقراری ارتباط عاطفی با دیگران چقدر پیش قدم می شوید؟", |
|||
}, |
|||
{ |
|||
question_number: 12, |
|||
factor_code: "L", |
|||
text: "تا چه اندازه از دیدن، وقت گذراندن و گفتگو با دوستان و آشنایان خود لذت می برید؟", |
|||
}, |
|||
{ |
|||
question_number: 13, |
|||
factor_code: "L", |
|||
text: "میزان بخشندگی، گذشت و فراموش کردن خطای دیگران را در خود چگونه ارزیابی می کنید؟", |
|||
}, |
|||
{ |
|||
question_number: 14, |
|||
factor_code: "L", |
|||
text: "تا چه اندازه به ایجاد و حفظ روابط دوستانه، صمیمی و پایدار با دیگران علاقه مند هستید؟", |
|||
}, |
|||
{ |
|||
question_number: 15, |
|||
factor_code: "P", |
|||
text: "تا چه اندازه تمایل دارید رهبری گروهها، هدایت پروژهها یا مدیریت امور را بر عهده بگیرید؟", |
|||
}, |
|||
{ |
|||
question_number: 16, |
|||
factor_code: "P", |
|||
text: "رسیدن به موفقیت، موقعیت اجتماعی ممتاز و جایگاه برتر تا چه حد برایتان اهمیت دارد؟", |
|||
}, |
|||
{ |
|||
question_number: 17, |
|||
factor_code: "P", |
|||
text: "در صورت بروز نقد، مخالفت یا اشتباه از سوی دیگران، تا چه حد رفتار تند، قاطع یا اصلاحی نشان میدهید؟", |
|||
}, |
|||
{ |
|||
question_number: 18, |
|||
factor_code: "P", |
|||
text: "تا چه اندازه تمایل دارید کارها دقیقاً طبق نظرات، استانداردها و روشهای شما انجام شوند؟", |
|||
}, |
|||
{ |
|||
question_number: 19, |
|||
factor_code: "P", |
|||
text: "در بحثها و گفتگوها تا چه حد اصرار دارید حرف و نظر خود را به عنوان نظر درست به اثبات برسانید؟", |
|||
}, |
|||
{ |
|||
question_number: 20, |
|||
factor_code: "P", |
|||
text: "وقتی احساس میکنید حق با شماست، تا چه اندازه حاضر به رقابت یا پافشاری بر روی خواستههایتان هستید؟", |
|||
}, |
|||
{ |
|||
question_number: 21, |
|||
factor_code: "P", |
|||
text: "دیدگاه شما نسبت به یادگیری، کسب دانش و بالا بردن اطلاعات تخصصی چیست؟", |
|||
}, |
|||
{ |
|||
question_number: 22, |
|||
factor_code: "Fr", |
|||
text: "تا چه اندازه تمایل دارید بدون دخالت، نظارت یا دستور دیگران اهداف شخصی خود را دنبال کنید؟", |
|||
}, |
|||
{ |
|||
question_number: 23, |
|||
factor_code: "Fr", |
|||
text: "در برابر احساس محدودیت، اجبار یا رعایت قوانین دست و پا گیر تا چه حد واکنش نشان میدهید؟", |
|||
}, |
|||
{ |
|||
question_number: 24, |
|||
factor_code: "Fr", |
|||
text: "میزان تمایل شما به داشتن حریم خصوصی، زمان تنهایی و استقلال در تصمیمگیریها چقدر است؟", |
|||
}, |
|||
{ |
|||
question_number: 25, |
|||
factor_code: "Fr", |
|||
text: "تا چه اندازه رهایی از قید و بندها و برنامههای فشرده را ترجیح میدهید؟", |
|||
}, |
|||
{ |
|||
question_number: 26, |
|||
factor_code: "Fr", |
|||
text: "در مواجهه با فشار روانی یا استرس، تا چه حد نیاز دارید فضا و وقت مستقل داشته باشید؟", |
|||
}, |
|||
{ |
|||
question_number: 27, |
|||
factor_code: "Fr", |
|||
text: "تا چه اندازه تمایل دارید مسیر زندگی، سبک پوشش یا رفتارهای خود را بر اساس معیارهای فردی انتخاب کنید؟", |
|||
}, |
|||
{ |
|||
question_number: 28, |
|||
factor_code: "Fr", |
|||
text: "چقدر برای آزادی اندیشه و ابراز دیدگاههای غیرمعمول اهمیت قائل هستید؟", |
|||
}, |
|||
{ |
|||
question_number: 29, |
|||
factor_code: "Fu", |
|||
text: "تا چه اندازه اهل شوخطبعی، خنده، لطیفهگویی و ایجاد فضای شاد هستید؟", |
|||
}, |
|||
{ |
|||
question_number: 30, |
|||
factor_code: "Fu", |
|||
text: "چقدر زمان و هزینه برای سرگرمی، تفریحات بیدغدغه و فعالیتهای مفرح اختصاص میدهید؟", |
|||
}, |
|||
{ |
|||
question_number: 31, |
|||
factor_code: "Fu", |
|||
text: "تا چه اندازه از کارهای خلاقانه، هنری، بازی و سرگرمی لذت میبرید؟", |
|||
}, |
|||
{ |
|||
question_number: 32, |
|||
factor_code: "Fu", |
|||
text: "چقدر قادر هستید لحظات سخت و جدیت زندگی را با نگاهی سبک، طنزآمیز و مثبت سپری کنید؟", |
|||
}, |
|||
{ |
|||
question_number: 33, |
|||
factor_code: "Fu", |
|||
text: "تمایل شما به شرکت در دورهمیها، برنامههای تفریحی و سفر با دوستان چقدر است؟", |
|||
}, |
|||
{ |
|||
question_number: 34, |
|||
factor_code: "Fu", |
|||
text: "چقدر برای داشتن سرگرمیها (هابی) و فعالیتهای غیرکاری وقت میگذارید؟", |
|||
}, |
|||
{ |
|||
question_number: 35, |
|||
factor_code: "Fu", |
|||
text: "از یادگیری چیزهای جدید همراه با بازی، هیجان و نشاط چقدر استقبال میکنید؟", |
|||
}, |
|||
]; |
|||
@ -1,345 +0,0 @@ |
|||
import enQuestions from "@/data/questions/en.json"; |
|||
import faQuestions from "@/data/questions/fa.json"; |
|||
import type { MarriageGender } from "@/hooks/marriage/types"; |
|||
import { defaultLocale, type Locale } from "@/translations/config"; |
|||
import { getDictionary } from "@/translations/dictionaries"; |
|||
|
|||
export const bookingTerms = [ |
|||
"You will be contacted by your consultant.", |
|||
"The call may start 10-15 minutes earlier or later than scheduled.", |
|||
"Make sure you are available and in a quiet place at least 10 minutes before the session.", |
|||
] as const; |
|||
|
|||
export type QuestionCardIcon = |
|||
| "profile" |
|||
| "education" |
|||
| "details" |
|||
| "checklist" |
|||
| "contact" |
|||
| "family_marital"; |
|||
|
|||
type QuestionExtras = { |
|||
placeHolder: string; |
|||
range: [number, number]; |
|||
options: string[]; |
|||
noSearch?: boolean; |
|||
}; |
|||
|
|||
type QuestionAudienceRule = { |
|||
genders?: MarriageGender[]; |
|||
maxAge?: number; |
|||
minAge?: number; |
|||
}; |
|||
|
|||
export type QuestionLogic = { |
|||
dependsOn: { |
|||
title: string; |
|||
values: string[]; |
|||
}; |
|||
}; |
|||
|
|||
export type QuestionField = { |
|||
title: string; |
|||
type: string; |
|||
required: boolean; |
|||
private?: boolean; |
|||
description: string; |
|||
tooltip: string; |
|||
extras: QuestionExtras; |
|||
audience?: QuestionAudienceRule; |
|||
requiredWhen?: QuestionAudienceRule; |
|||
logic?: QuestionLogic; |
|||
showGuardianNotice?: boolean; |
|||
originalSlug?: string; |
|||
originalIndex?: number; |
|||
englishTitle?: string; |
|||
}; |
|||
|
|||
export type QuestionListItem = { |
|||
slug: string; |
|||
title: string; |
|||
estimate: string; |
|||
progress: number; |
|||
icon: QuestionCardIcon; |
|||
required?: boolean; |
|||
note?: string; |
|||
showInfoBadge?: boolean; |
|||
summary: string; |
|||
checkpoints: readonly string[]; |
|||
tooltip: string; |
|||
questions: readonly QuestionField[]; |
|||
audience?: QuestionAudienceRule; |
|||
}; |
|||
|
|||
type RawQuestionListItem = { |
|||
title: string; |
|||
icon: string; |
|||
slug: string; |
|||
required?: boolean; |
|||
estimateTime: string; |
|||
tooltip: string; |
|||
progress: number; |
|||
description: string; |
|||
questions: QuestionField[]; |
|||
audience?: QuestionAudienceRule; |
|||
}; |
|||
|
|||
const iconMap: Record<string, QuestionCardIcon> = { |
|||
"user-circle": "profile", |
|||
school: "education", |
|||
"heart-handshake": "details", |
|||
"file-text": "contact", |
|||
"layout-grid": "checklist", |
|||
}; |
|||
|
|||
const questionsByLocale: Record<string, RawQuestionListItem[]> = { |
|||
en: enQuestions as RawQuestionListItem[], |
|||
fa: faQuestions as RawQuestionListItem[], |
|||
}; |
|||
|
|||
function mapQuestionListItem(item: RawQuestionListItem): QuestionListItem { |
|||
return { |
|||
slug: item.slug, |
|||
title: item.title, |
|||
estimate: item.estimateTime, |
|||
progress: item.progress, |
|||
icon: iconMap[item.icon] ?? "details", |
|||
required: item.required, |
|||
showInfoBadge: Boolean(item.tooltip), |
|||
summary: item.description, |
|||
checkpoints: item.questions.map((question) => question.title), |
|||
tooltip: item.tooltip, |
|||
questions: item.questions, |
|||
}; |
|||
} |
|||
|
|||
export function getQuestionListItems(locale: Locale = defaultLocale) { |
|||
const rawItems = |
|||
questionsByLocale[locale] ?? |
|||
questionsByLocale[defaultLocale] ?? |
|||
questionsByLocale.en ?? |
|||
[]; |
|||
|
|||
const enItems = questionsByLocale.en || []; |
|||
const mappedRawItems = rawItems.map((item) => { |
|||
const enItem = enItems.find((e) => e.slug === item.slug); |
|||
if (!enItem) return item; |
|||
return { |
|||
...item, |
|||
questions: item.questions.map((q, idx) => { |
|||
const enQ = enItem.questions[idx]; |
|||
return { |
|||
...q, |
|||
englishTitle: enQ ? enQ.title : q.title, |
|||
}; |
|||
}), |
|||
}; |
|||
}); |
|||
|
|||
const items = mappedRawItems.map(mapQuestionListItem); |
|||
|
|||
const fbIndex = items.findIndex((item) => item.slug === "family_background"); |
|||
const mhIndex = items.findIndex( |
|||
(item) => item.slug === "marital_history_children", |
|||
); |
|||
|
|||
if (fbIndex !== -1 && mhIndex !== -1) { |
|||
const fbItem = items[fbIndex]; |
|||
const mhItem = items[mhIndex]; |
|||
|
|||
const dict = getDictionary(locale); |
|||
const mergedTitle = |
|||
(dict as any)["Family Background, Marital Status, and Children"] || |
|||
"Family Background, Marital Status, and Children"; |
|||
const mergedEstimate = (dict as any)["20 minutes"] || "20 minutes"; |
|||
|
|||
const fbQuestions = fbItem.questions.map((q, idx) => ({ |
|||
...q, |
|||
originalSlug: "family_background", |
|||
originalIndex: idx, |
|||
})); |
|||
|
|||
const mhQuestions = mhItem.questions.map((q, idx) => ({ |
|||
...q, |
|||
originalSlug: "marital_history_children", |
|||
originalIndex: idx, |
|||
})); |
|||
|
|||
const finalQuestions = [...fbQuestions, ...mhQuestions]; |
|||
|
|||
const mergedItem: QuestionListItem = { |
|||
slug: "family_marital_history", |
|||
title: mergedTitle, |
|||
estimate: mergedEstimate, |
|||
progress: 0, |
|||
icon: "family_marital", |
|||
required: fbItem.required || mhItem.required, |
|||
showInfoBadge: fbItem.showInfoBadge || mhItem.showInfoBadge, |
|||
summary: `${fbItem.summary}\n\n${mhItem.summary}`, |
|||
checkpoints: [...fbItem.checkpoints, ...mhItem.checkpoints], |
|||
tooltip: fbItem.tooltip || mhItem.tooltip, |
|||
questions: finalQuestions, |
|||
}; |
|||
|
|||
const newItems = [...items]; |
|||
newItems[fbIndex] = mergedItem; |
|||
newItems.splice(mhIndex, 1); |
|||
|
|||
// Reorder items: bring future_spouse_criteria and identity_verification above the tests
|
|||
const personalityIndex = newItems.findIndex( |
|||
(item) => item.slug === "personality_test", |
|||
); |
|||
const spouseItem = newItems.find( |
|||
(item) => item.slug === "future_spouse_criteria", |
|||
); |
|||
const identityItem = newItems.find( |
|||
(item) => item.slug === "identity_verification", |
|||
); |
|||
|
|||
if (personalityIndex !== -1 && (spouseItem || identityItem)) { |
|||
const filtered = newItems.filter( |
|||
(item) => |
|||
item.slug !== "future_spouse_criteria" && |
|||
item.slug !== "identity_verification", |
|||
); |
|||
const insertAt = filtered.findIndex( |
|||
(item) => item.slug === "personality_test", |
|||
); |
|||
if (insertAt !== -1) { |
|||
const result = [...filtered]; |
|||
const itemsToInsert = []; |
|||
if (spouseItem) itemsToInsert.push(spouseItem); |
|||
if (identityItem) itemsToInsert.push(identityItem); |
|||
result.splice(insertAt, 0, ...itemsToInsert); |
|||
return result; |
|||
} |
|||
} |
|||
return newItems; |
|||
} |
|||
|
|||
// Also handle reordering if fbIndex/mhIndex are not found (fallback)
|
|||
const personalityIndex = items.findIndex( |
|||
(item) => item.slug === "personality_test", |
|||
); |
|||
const spouseItem = items.find( |
|||
(item) => item.slug === "future_spouse_criteria", |
|||
); |
|||
const identityItem = items.find( |
|||
(item) => item.slug === "identity_verification", |
|||
); |
|||
|
|||
if (personalityIndex !== -1 && (spouseItem || identityItem)) { |
|||
const filtered = items.filter( |
|||
(item) => |
|||
item.slug !== "future_spouse_criteria" && |
|||
item.slug !== "identity_verification", |
|||
); |
|||
const insertAt = filtered.findIndex( |
|||
(item) => item.slug === "personality_test", |
|||
); |
|||
if (insertAt !== -1) { |
|||
const result = [...filtered]; |
|||
const itemsToInsert = []; |
|||
if (spouseItem) itemsToInsert.push(spouseItem); |
|||
if (identityItem) itemsToInsert.push(identityItem); |
|||
result.splice(insertAt, 0, ...itemsToInsert); |
|||
return result; |
|||
} |
|||
} |
|||
|
|||
return items; |
|||
} |
|||
|
|||
export function getQuestionListItemBySlug( |
|||
slug: string, |
|||
locale: Locale = defaultLocale, |
|||
) { |
|||
return getQuestionListItems(locale).find((item) => item.slug === slug); |
|||
} |
|||
|
|||
function matchesAudienceRule( |
|||
rule: QuestionAudienceRule | undefined, |
|||
profile: { |
|||
age?: number | null; |
|||
gender?: MarriageGender | null; |
|||
}, |
|||
) { |
|||
if (!rule) { |
|||
return true; |
|||
} |
|||
|
|||
if ( |
|||
rule.genders?.length && |
|||
profile.gender && |
|||
!rule.genders.includes(profile.gender) |
|||
) { |
|||
return false; |
|||
} |
|||
|
|||
if ( |
|||
typeof rule.minAge === "number" && |
|||
profile.age != null && |
|||
profile.age < rule.minAge |
|||
) { |
|||
return false; |
|||
} |
|||
|
|||
if ( |
|||
typeof rule.maxAge === "number" && |
|||
profile.age != null && |
|||
profile.age > rule.maxAge |
|||
) { |
|||
return false; |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
export function isQuestionListItemVisibleForProfile( |
|||
item: QuestionListItem, |
|||
profile: { |
|||
age?: number | null; |
|||
gender?: MarriageGender | null; |
|||
}, |
|||
) { |
|||
return matchesAudienceRule(item.audience, profile); |
|||
} |
|||
|
|||
export function isQuestionVisibleForProfile( |
|||
question: QuestionField, |
|||
profile: { |
|||
age?: number | null; |
|||
gender?: MarriageGender | null; |
|||
}, |
|||
) { |
|||
return matchesAudienceRule(question.audience, profile); |
|||
} |
|||
|
|||
export function isQuestionRequiredForProfile( |
|||
question: QuestionField, |
|||
profile: { |
|||
age?: number | null; |
|||
gender?: MarriageGender | null; |
|||
}, |
|||
) { |
|||
return ( |
|||
question.required || |
|||
(Boolean(question.requiredWhen) && |
|||
matchesAudienceRule(question.requiredWhen, profile)) |
|||
); |
|||
} |
|||
|
|||
export function getRequiredQuestionsCount( |
|||
slug: string, |
|||
profile: { |
|||
age?: number | null; |
|||
gender?: MarriageGender | null; |
|||
}, |
|||
locale: Locale = defaultLocale, |
|||
) { |
|||
const item = getQuestionListItemBySlug(slug, locale); |
|||
if (!item) return 0; |
|||
|
|||
return item.questions.filter((q) => isQuestionRequiredForProfile(q, profile)) |
|||
.length; |
|||
} |
|||
1870
src/data/questions/en.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
1870
src/data/questions/fa.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -1,47 +0,0 @@ |
|||
/** |
|||
* Maps backend section slugs to their corresponding frontend question list slugs. |
|||
* Some backend sections aggregate multiple frontend question categories under a |
|||
* single slug, so these two naming systems don't always match 1-to-1. |
|||
*/ |
|||
export const BACKEND_TO_FRONTEND_SLUG_MAP: Record<string, string> = { |
|||
personal_identity: "personal_info", |
|||
contact_residence: "contact_residence_family_communication", |
|||
appearance_health: "appearance_health_activity", |
|||
education_career: "education_career_economic_status", |
|||
family_background: "family_marital_history", |
|||
marital_history: "family_marital_history", |
|||
beliefs_lifestyle: "beliefs_lifestyle_boundaries", |
|||
spouse_criteria: "future_spouse_criteria", |
|||
documents_verification: "identity_verification", |
|||
}; |
|||
|
|||
/** |
|||
* Reverse map: frontend slug → backend slug. |
|||
* Built automatically from BACKEND_TO_FRONTEND_SLUG_MAP. |
|||
*/ |
|||
export const FRONTEND_TO_BACKEND_SLUG_MAP: Record<string, string> = |
|||
Object.fromEntries( |
|||
Object.entries(BACKEND_TO_FRONTEND_SLUG_MAP).map(([backend, frontend]) => [ |
|||
frontend, |
|||
backend, |
|||
]), |
|||
); |
|||
|
|||
/** |
|||
* Resolve a frontend question-list slug to the backend section slug |
|||
* the API expects. Falls back to the original slug when no mapping exists. |
|||
*/ |
|||
export function toBackendSlug(frontendSlug: string): string { |
|||
if (frontendSlug === "marital_history_children") { |
|||
return "marital_history"; |
|||
} |
|||
return FRONTEND_TO_BACKEND_SLUG_MAP[frontendSlug] ?? frontendSlug; |
|||
} |
|||
|
|||
/** |
|||
* Resolve a backend section slug to the frontend question-list slug |
|||
* used in the JSON data. Falls back to the original slug. |
|||
*/ |
|||
export function toFrontendSlug(backendSlug: string): string { |
|||
return BACKEND_TO_FRONTEND_SLUG_MAP[backendSlug] ?? backendSlug; |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'; |
|||
import { getSubmitPath } from './get-submit-path'; |
|||
import * as firstEntryHelper from './first-entry-helper'; |
|||
import * as matchStartGrace from './match-start-grace'; |
|||
import type { MarriageProfileResponse } from '@/hooks/marriage/types'; |
|||
|
|||
vi.mock('./first-entry-helper', () => ({ |
|||
isFirstEntryCompleted: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock('./match-start-grace', () => ({ |
|||
clearLegacyMatchSubmittedFlag: vi.fn(), |
|||
isWithinMatchStartGrace: vi.fn(), |
|||
})); |
|||
|
|||
describe('getSubmitPath', () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
vi.mocked(firstEntryHelper.isFirstEntryCompleted).mockReturnValue(true); |
|||
vi.mocked(matchStartGrace.isWithinMatchStartGrace).mockReturnValue(false); |
|||
}); |
|||
|
|||
const baseProfile: MarriageProfileResponse = { |
|||
status: 'pending_onboarding', |
|||
id: 1, |
|||
gender: 'male', |
|||
is_registering_for_self: true, |
|||
}; |
|||
|
|||
it('pending_onboarding returns /terms', () => { |
|||
expect(getSubmitPath({ ...baseProfile, status: 'pending_onboarding' })).toBe('/terms'); |
|||
}); |
|||
|
|||
it('waiting returns /finding-match', () => { |
|||
expect(getSubmitPath({ ...baseProfile, status: 'waiting' })).toBe('/finding-match'); |
|||
}); |
|||
|
|||
it('pending_info returns questions path', () => { |
|||
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list'); |
|||
|
|||
vi.mocked(firstEntryHelper.isFirstEntryCompleted).mockReturnValue(false); |
|||
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list/personal_info'); |
|||
}); |
|||
|
|||
it('matched returns /request-accepted', () => { |
|||
expect(getSubmitPath({ ...baseProfile, status: 'matched' })).toBe('/request-accepted'); |
|||
}); |
|||
|
|||
describe('active_case priority', () => { |
|||
it('returns /new-match when status="pending_info" but active_case is introduced and action pending', () => { |
|||
const profileWithCase = { |
|||
...baseProfile, |
|||
status: 'pending_info' as const, |
|||
active_case: { |
|||
status: 'introduced' as const, |
|||
my_action: 'pending' as const, |
|||
}, |
|||
}; |
|||
// Type assertion added because the mock profile object is lacking many properties of the full MarriageProfileResponse,
|
|||
// but it contains everything needed for the logic being tested.
|
|||
expect(getSubmitPath(profileWithCase as any)).toBe('/new-match'); |
|||
}); |
|||
|
|||
it('returns /request-accepted when status="pending_info" but active_case is payment_done', () => { |
|||
const profileWithCase = { |
|||
...baseProfile, |
|||
status: 'pending_info' as const, |
|||
active_case: { |
|||
status: 'payment_done' as const, |
|||
my_action: 'pending' as const, |
|||
}, |
|||
}; |
|||
expect(getSubmitPath(profileWithCase as any)).toBe('/request-accepted'); |
|||
}); |
|||
|
|||
it('returns /request-sent when status="waiting" but active_case is male_accepted and action done', () => { |
|||
const profileWithCase = { |
|||
...baseProfile, |
|||
status: 'waiting' as const, |
|||
active_case: { |
|||
status: 'male_accepted' as const, |
|||
my_action: 'done' as const, |
|||
}, |
|||
}; |
|||
expect(getSubmitPath(profileWithCase as any)).toBe('/request-sent'); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,144 @@ |
|||
import type { |
|||
FormSchemaResponse, |
|||
FormSection, |
|||
FormQuestion, |
|||
} from "@/hooks/marriage/use-form-schema"; |
|||
import { defaultLocale, type Locale } from "@/translations/config"; |
|||
|
|||
export type QuestionCardIcon = |
|||
| "profile" |
|||
| "education" |
|||
| "details" |
|||
| "checklist" |
|||
| "contact" |
|||
| "family_marital"; |
|||
|
|||
export type QuestionExtras = { |
|||
placeHolder: string; |
|||
range: [number, number]; |
|||
options: string[]; |
|||
noSearch?: boolean; |
|||
}; |
|||
|
|||
export type QuestionAudienceRule = { |
|||
genders?: string[]; |
|||
maxAge?: number; |
|||
minAge?: number; |
|||
}; |
|||
|
|||
|
|||
|
|||
export type QuestionField = { |
|||
id: string; |
|||
title: string; |
|||
type: string; |
|||
order: number; |
|||
required: boolean; |
|||
baseRequired: boolean; |
|||
isVisible: boolean; |
|||
private?: boolean; |
|||
description: string; |
|||
tooltip: string; |
|||
validation?: any; |
|||
ui_config?: any; |
|||
extras: QuestionExtras; |
|||
audience?: QuestionAudienceRule; |
|||
requiredWhen?: QuestionAudienceRule; |
|||
showGuardianNotice?: boolean; |
|||
options: { id: string; value: string | number; label: string; order: number }[]; |
|||
}; |
|||
|
|||
export type QuestionListItem = { |
|||
slug: string; |
|||
title: string; |
|||
estimate: string; |
|||
progress: number; |
|||
icon: QuestionCardIcon; |
|||
required: boolean; |
|||
showInfoBadge?: boolean; |
|||
summary: string; |
|||
checkpoints: string[]; |
|||
tooltip?: string; |
|||
note?: string; |
|||
questions: QuestionField[]; |
|||
}; |
|||
|
|||
const iconMap: Record<string, QuestionCardIcon> = { |
|||
"user-circle": "profile", |
|||
school: "education", |
|||
"heart-handshake": "details", |
|||
"file-text": "contact", |
|||
"layout-grid": "checklist", |
|||
}; |
|||
|
|||
export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): QuestionField { |
|||
return { |
|||
id: bq.id, |
|||
title: bq.title || "Untitled", |
|||
type: bq.type, |
|||
order: bq.order !== undefined ? bq.order : index, |
|||
required: bq.is_required !== undefined ? bq.is_required : bq.required, |
|||
baseRequired: bq.required, |
|||
isVisible: bq.is_visible, |
|||
private: bq.ui_config?.private, |
|||
validation: bq.validation, |
|||
ui_config: bq.ui_config, |
|||
description: bq.description || "", |
|||
tooltip: bq.tooltip || "", |
|||
extras: { |
|||
placeHolder: bq.placeholder || "", |
|||
options: bq.options?.map((o) => o.label) || [], |
|||
range: bq.ui_config?.range || [0, 0], |
|||
noSearch: bq.ui_config?.noSearch, |
|||
}, |
|||
showGuardianNotice: bq.show_guardian_notice, |
|||
options: [...(bq.options || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), |
|||
}; |
|||
} |
|||
|
|||
export function mapBackendSectionToFrontend( |
|||
section: FormSection, |
|||
progress: number |
|||
): QuestionListItem { |
|||
const allQuestions: QuestionField[] = []; |
|||
let index = 0; |
|||
|
|||
const cards = [...(section.cards || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); |
|||
cards.forEach((card) => { |
|||
const questions = [...(card.questions || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); |
|||
questions.forEach((q) => { |
|||
const fq = mapBackendQuestionToFrontend(q, index); |
|||
allQuestions.push(fq); |
|||
index++; |
|||
}); |
|||
}); |
|||
|
|||
return { |
|||
slug: section.id, |
|||
title: section.title, |
|||
estimate: section.estimated_minutes ? `${section.estimated_minutes} min` : "5 min", |
|||
progress: progress, |
|||
icon: iconMap[section.icon] ?? "details", |
|||
required: section.is_required, |
|||
showInfoBadge: false, |
|||
summary: "", |
|||
checkpoints: allQuestions.map((q) => q.title), |
|||
tooltip: "", |
|||
questions: allQuestions, |
|||
}; |
|||
} |
|||
|
|||
export function convertSchemaToFrontendItems( |
|||
schema: FormSchemaResponse | undefined, |
|||
locale: Locale = defaultLocale |
|||
): QuestionListItem[] { |
|||
if (!schema) return []; |
|||
|
|||
const rawItems = schema.sections.map((sec) => { |
|||
const progInfo = schema.progress?.sections_progress?.[sec.id]; |
|||
const progress = progInfo ? progInfo.completion_percent : 0; |
|||
return mapBackendSectionToFrontend(sec, progress); |
|||
}); |
|||
|
|||
return rawItems.sort((a, b) => (schema.sections.find(s => s.id === a.slug)?.order ?? 0) - (schema.sections.find(s => s.id === b.slug)?.order ?? 0)); |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue