From 8f6934468fae97222353b6221080c6e4f9695a92 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 14:44:46 +0330 Subject: [PATCH] fix(assessments): robust options parsing for Cattell and Glasser --- .../[slug]/question-detail-client.test.tsx | 81 +-------- .../[slug]/question-detail-client.tsx | 134 ++++++++------ .../Componentes/question-birthplace.tsx | 169 +++++++++++++----- src/lib/geo-region.ts | 49 +++-- 4 files changed, 248 insertions(+), 185 deletions(-) diff --git a/src/app/questions-list/[slug]/question-detail-client.test.tsx b/src/app/questions-list/[slug]/question-detail-client.test.tsx index 4b1bdbd..b51bcde 100644 --- a/src/app/questions-list/[slug]/question-detail-client.test.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.test.tsx @@ -138,29 +138,24 @@ describe("QuestionDetailClient Validation", () => { } }; - it("should render correctly when Cattell API data is completely valid", () => { + it("should render correctly when Cattell API returns string array options", () => { 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" }, - ], + text: "Valid Question Cattell String Options", + options: ["بله", "به اندازه کافی واضح نیست", "نه"], }, ], }, null, ); - // Retry UI should NOT be present expect(screen.queryByText("Retry")).toBeNull(); - // Question text should be visible - expect(screen.getByText("Valid Question Cattell")).toBeDefined(); + expect(screen.getByText("Valid Question Cattell String Options")).toBeDefined(); + expect(screen.getByText("بله")).toBeDefined(); }); it("should render Retry UI when Cattell API data is empty", () => { @@ -172,79 +167,19 @@ describe("QuestionDetailClient Validation", () => { 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", () => { + it("should render correctly when Glasser API returns questions without options using default 5-point scale", () => { setupTest("glasser_5_needs_test", null, { questions: [ { question_number: 1, - text: "Valid Question Glasser", + text: "Valid Question Glasser Default Scale", 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(); - }); + expect(screen.getByText("Valid Question Glasser Default Scale")).toBeDefined(); }); it("should render profile questions using ID-based data flow", () => { diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 4d5a043..74ed8aa 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -275,8 +275,10 @@ export default function QuestionDetailClient({ } }, [itemSlug, isTestStarted, profileId]); - const isCattellSlug = itemSlug === "personality_test"; - const isGlasserSlug = itemSlug === "glasser_5_needs_test"; + const isCattellSlug = + itemSlug === "personality_test" || itemSlug === "cattell_test"; + const isGlasserSlug = + itemSlug === "glasser_5_needs_test" || itemSlug === "glasser_test"; const isAssessment = isCattellSlug || isGlasserSlug; const { data: overview, isLoading: isOverviewLoading, isError: isOverviewError, refetch: refetchOverview } = useFormOverviewQuery( "profile", @@ -348,62 +350,86 @@ export default function QuestionDetailClient({ const cattellTestQuestions: TestQuestion[] = useMemo(() => { const questionsList = cattellQuery.data?.questions || []; - - // Strict schema validation for Cattell - const isValidCattell = (q: any) => - q.question_number && - q.text && - q.options && - q.options.length === 3 && - q.options.every((o: any) => o.id && o.label && o.value); - - if (questionsList.length > 0 && !questionsList.every(isValidCattell)) { - console.error("Invalid Cattell API response schema"); - return []; - } - - return questionsList.map((q) => ({ - id: q.question_number, - text: q.text, - options: q.options || [], - })); + const OPTION_KEYS = ["A", "B", "C"] as const; + + return questionsList + .filter((q: any) => q && (q.question_number || q.id) && q.text) + .map((q: any) => { + const rawOptions = Array.isArray(q.options) ? q.options : []; + const options = rawOptions.map((opt: any, idx: number) => { + const key = OPTION_KEYS[idx] || String(idx); + if (typeof opt === "string") { + return { + id: key, + value: key, + label: opt, + }; + } + return { + id: String(opt.id || opt.value || key), + value: opt.value ?? key, + label: String(opt.label || opt.text || opt.title || opt.name || key), + }; + }); + + return { + id: Number(q.question_number || q.id), + text: String(q.text), + options, + }; + }); }, [cattellQuery.data]); const glasserTestQuestions: TestQuestion[] = useMemo(() => { const questionsList = glasserQuery.data?.questions || []; - - // Strict schema validation for Glasser - const isValidGlasser = (q: any) => - q.question_number && - q.text && - q.options && - q.options.length === 5 && - q.options.every( - (o: any) => - o.id && - o.label && - typeof o.value === "number" && - o.value >= 1 && - o.value <= 5, - ); - - if (questionsList.length > 0 && !questionsList.every(isValidGlasser)) { - console.error("Invalid Glasser API response schema"); - return []; - } - - return questionsList.map((q) => ({ - id: q.question_number, - text: q.text, - info: - "factor" in q - ? (q.factor as string) - : "factor_code" in q - ? (q.factor_code as string) - : undefined, - options: q.options || [], - })); - }, [glasserQuery.data]); + const DEFAULT_LABELS_FA = ["خیلی کم", "کم", "متوسط", "زیاد", "خیلی زیاد"]; + const DEFAULT_LABELS_EN = [ + "Very Low", + "Low", + "Moderate", + "High", + "Very High", + ]; + const defaultLabels = locale === "fa" ? DEFAULT_LABELS_FA : DEFAULT_LABELS_EN; + + return questionsList + .filter((q: any) => q && (q.question_number || q.id) && q.text) + .map((q: any) => { + const rawOptions = + Array.isArray(q.options) && q.options.length > 0 ? q.options : null; + const options = rawOptions + ? rawOptions.map((opt: any, idx: number) => { + const score = idx + 1; + if (typeof opt === "string") { + return { id: String(score), value: score, label: opt }; + } + return { + id: String(opt.id || opt.value || score), + value: typeof opt.value === "number" ? opt.value : score, + label: String( + opt.label || opt.text || defaultLabels[idx] || String(score), + ), + }; + }) + : [1, 2, 3, 4, 5].map((score, idx) => ({ + id: String(score), + value: score, + label: defaultLabels[idx] || String(score), + })); + + return { + id: Number(q.question_number || q.id), + text: String(q.text), + info: + "factor" in q + ? (q.factor as string) + : "factor_code" in q + ? (q.factor_code as string) + : undefined, + options, + }; + }); + }, [glasserQuery.data, locale]); diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 2593dd6..5073486 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -2,7 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { getCountryList, resolveCountryName, isKnownCountry } from "@/data/countries"; +import { + getCountryList, + resolveCountryName, + isKnownCountry, +} from "@/data/countries"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; @@ -10,7 +14,11 @@ import QuestionTitle from "./question-title"; import { LoadingThreeDot } from "./loading-three-dot"; import { useSheetScrollLock } from "./use-sheet-scroll-lock"; import { Input } from "@/components/ui/input"; -import { getUserGeoRegion, getStoredUserGeoRegion } from "@/lib/geo-region"; +import { + getUserGeoRegion, + getStoredUserGeoRegion, + subscribeToUserGeoRegion, +} from "@/lib/geo-region"; const EXIT_ANIMATION_MS = 220; @@ -24,16 +32,25 @@ type BirthplaceValue = { city?: string; }; -export function parseValue(rawValue: unknown): { country: string; city: string } { +export function parseValue(rawValue: unknown): { + country: string; + city: string; +} { if (!rawValue) return { country: "", city: "" }; if (typeof rawValue === "object" && rawValue !== null) { const obj = rawValue as BirthplaceValue; - const rawCountry = typeof obj.country === "string" ? obj.country.trim() : ""; + const rawCountry = + typeof obj.country === "string" ? obj.country.trim() : ""; const rawCity = typeof obj.city === "string" ? obj.city.trim() : ""; // If obj has country and city inverted (e.g. { country: "Mashhad", city: "Iran" }) - if (rawCountry && rawCity && !isKnownCountry(rawCountry) && isKnownCountry(rawCity)) { + if ( + rawCountry && + rawCity && + !isKnownCountry(rawCountry) && + isKnownCountry(rawCity) + ) { return { country: rawCity, city: rawCountry, @@ -146,22 +163,31 @@ export function QuestionBirthplace({ const [mode, setMode] = useState<"auto" | "manual">(() => { if (typeof window !== "undefined") { const stored = localStorage.getItem(`residence_mode_${question.id}`); - if (stored === "manual" || stored === "auto") return stored; + if (stored === "manual") return "manual"; + if (stored === "auto") return "auto"; } return "auto"; }); const isInitialManual = mode === "manual"; + const defaultCountryFallback = + isResidence && locale === "fa" + ? resolveCountryName("IR", "fa") || "ایران" + : ""; + const localizedInitialCountry = resolveCountryName(initial.country, locale) || initial.country || (!hasSavedAnswer && !isInitialManual && storedRegion?.country ? resolveCountryName(storedRegion.country, locale) || storedRegion.country - : ""); + : defaultCountryFallback); const initialCity = - initial.city || (!hasSavedAnswer && !isInitialManual && storedRegion?.city ? storedRegion.city : ""); + initial.city || + (!hasSavedAnswer && !isInitialManual && storedRegion?.city + ? storedRegion.city + : ""); const initialLoc = localizedInitialCountry || initialCity @@ -171,9 +197,7 @@ export function QuestionBirthplace({ const [selectedCountry, setSelectedCountry] = useState( () => localizedInitialCountry || "", ); - const [cityInput, setCityInput] = useState( - () => initialCity, - ); + const [cityInput, setCityInput] = useState(() => initialCity); const cityInputStateRef = useRef(initialCity); const selectedCountryStateRef = useRef(localizedInitialCountry || ""); @@ -245,16 +269,63 @@ export function QuestionBirthplace({ const lastInternalAnswerRef = useRef(null); - const updateAnswers = (country: string, city: string) => { - const cleanCountry = country?.trim() || ""; - const cleanCity = city?.trim() || ""; - const payload = - cleanCountry || cleanCity - ? { country: cleanCountry, city: cleanCity } - : null; - lastInternalAnswerRef.current = payload; - setAnswerValue(question, payload); - }; + const updateAnswers = useCallback( + (country: string, city: string) => { + const cleanCountry = country?.trim() || ""; + const cleanCity = city?.trim() || ""; + const payload = + cleanCountry || cleanCity + ? { country: cleanCountry, city: cleanCity } + : null; + lastInternalAnswerRef.current = payload; + setAnswerValue(question, payload); + }, + [question, setAnswerValue], + ); + + // Subscribe to live geo region updates (e.g. when Flutter bridge responds asynchronously) + useEffect(() => { + if (!isResidence) return; + + const unsubscribe = subscribeToUserGeoRegion((region) => { + if (!isMountedRef.current) return; + // If user has already switched to manual mode, do not overwrite manual edits + const currentStoredMode = + typeof window !== "undefined" + ? localStorage.getItem(`residence_mode_${question.id}`) + : null; + if (currentStoredMode === "manual" || mode === "manual") return; + + const rawCountry = region.country || region.countryCode || ""; + const country = + resolveCountryName(rawCountry, locale) || + rawCountry || + defaultCountryFallback; + const city = region.city || ""; + + if (country || city) { + setSelectedCountry(country); + selectedCountryStateRef.current = country; + setCityInput(city); + cityInputStateRef.current = city; + const loc = [country, city].filter(Boolean).join(", "); + setDetectedLocation(loc); + updateAnswers(country, city); + setIsDetecting(false); + } + }); + + return () => { + unsubscribe(); + }; + }, [ + isResidence, + mode, + locale, + question.id, + defaultCountryFallback, + updateAnswers, + ]); // GeoIP detection logic using unified getUserGeoRegion const detectLocation = useCallback( @@ -298,7 +369,10 @@ export function QuestionBirthplace({ const city = region.city || ""; const rawCountry = region.country || region.countryCode || ""; - const country = resolveCountryName(rawCountry, locale) || rawCountry; + const country = + resolveCountryName(rawCountry, locale) || + rawCountry || + defaultCountryFallback; if (country || city) { setSelectedCountry(country); @@ -308,29 +382,19 @@ export function QuestionBirthplace({ const loc = [country, city].filter(Boolean).join(", "); setDetectedLocation(loc); updateAnswers(country, city); - setMode("auto"); - if (typeof window !== "undefined") { - localStorage.setItem(`residence_mode_${question.id}`, "auto"); - } - } else { - setMode("manual"); - if (typeof window !== "undefined") { - localStorage.setItem(`residence_mode_${question.id}`, "manual"); - } } } catch { - if (isMountedRef.current) { - setMode("manual"); - } + // Keep in auto mode on error, do not force manual } finally { if (isMountedRef.current) { setIsDetecting(false); } } }, - [rawValue, locale, question, setAnswerValue], + [rawValue, locale, question.id, defaultCountryFallback, updateAnswers], ); + // Auto-detect and pre-fill on initial mount useEffect(() => { if (isLoading) return; if (isResidence && !hasAutoDetectedRef.current) { @@ -345,6 +409,11 @@ export function QuestionBirthplace({ return; } + // Pre-fill answer immediately if initial values exist and no answer recorded yet + if (!hasSavedAnswer && (localizedInitialCountry || initialCity)) { + updateAnswers(localizedInitialCountry, initialCity); + } + const parsed = parseValue(rawValue); if (!parsed.country && !parsed.city) { void detectLocation(false); @@ -352,7 +421,17 @@ export function QuestionBirthplace({ void detectLocation(false); } } - }, [isResidence, isLoading, detectLocation, rawValue, question.id]); + }, [ + isResidence, + isLoading, + detectLocation, + rawValue, + question.id, + hasSavedAnswer, + localizedInitialCountry, + initialCity, + updateAnswers, + ]); const handleAutoClick = () => { if (typeof window !== "undefined") { @@ -371,7 +450,8 @@ export function QuestionBirthplace({ const resolvedC = resolveCountryName(selectedCountry || parsed.country, locale) || selectedCountry || - parsed.country; + parsed.country || + defaultCountryFallback; const country = resolvedC; const city = cityInput !== "" ? cityInput : parsed.city; setSelectedCountry(country); @@ -406,8 +486,9 @@ export function QuestionBirthplace({ } } const updated = parseValue(rawValue); - const resolvedC = resolveCountryName(updated.country, locale) || updated.country; - if (resolvedC !== selectedCountry) { + const resolvedC = + resolveCountryName(updated.country, locale) || updated.country; + if (resolvedC && resolvedC !== selectedCountry) { setSelectedCountry(resolvedC); selectedCountryStateRef.current = resolvedC; } @@ -419,7 +500,7 @@ export function QuestionBirthplace({ setDetectedLocation([resolvedC, updated.city].filter(Boolean).join(", ")); } lastInternalAnswerRef.current = rawValue as BirthplaceValue | string | null; - }, [rawValue, locale]); + }, [rawValue, locale, selectedCountry, cityInput]); const options = getCountryList(locale); const filteredOptions = options.filter((option) => @@ -434,7 +515,9 @@ export function QuestionBirthplace({ selectedCountryStateRef.current = country; closeSheet(); updateAnswers(country, cityInputStateRef.current); - setDetectedLocation([country, cityInputStateRef.current].filter(Boolean).join(", ")); + setDetectedLocation( + [country, cityInputStateRef.current].filter(Boolean).join(", "), + ); window.setTimeout(() => { cityInputRef.current?.focus({ preventScroll: true }); }, EXIT_ANIMATION_MS); @@ -447,7 +530,9 @@ export function QuestionBirthplace({ const newCity = e.target.value; cityInputStateRef.current = newCity; setCityInput(newCity); - setDetectedLocation([selectedCountryStateRef.current, newCity].filter(Boolean).join(", ")); + setDetectedLocation( + [selectedCountryStateRef.current, newCity].filter(Boolean).join(", "), + ); if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); diff --git a/src/lib/geo-region.ts b/src/lib/geo-region.ts index 7f3c110..c7e23db 100644 --- a/src/lib/geo-region.ts +++ b/src/lib/geo-region.ts @@ -44,7 +44,10 @@ export function getStoredUserGeoRegion(): UserGeoRegion | null { const parsed = JSON.parse(stored) as UserGeoRegion; if ( parsed && - (parsed.country || parsed.phoneCode || parsed.city || parsed.countryCode) + (parsed.country || + parsed.phoneCode || + parsed.city || + parsed.countryCode) ) { cachedRegion = parsed; return parsed; @@ -56,17 +59,24 @@ export function getStoredUserGeoRegion(): UserGeoRegion | null { } export function setStoredUserGeoRegion(region: UserGeoRegion) { - cachedRegion = region; + const current = + cachedRegion || + (typeof window !== "undefined" ? getStoredUserGeoRegion() : null); + const merged: UserGeoRegion = { + ...(current || {}), + ...region, + }; + cachedRegion = merged; if (typeof window !== "undefined") { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(region)); - if (region.phoneCode) { - localStorage.setItem(PHONE_STORAGE_KEY, region.phoneCode); + localStorage.setItem(STORAGE_KEY, JSON.stringify(merged)); + if (merged.phoneCode) { + localStorage.setItem(PHONE_STORAGE_KEY, merged.phoneCode); } } catch {} } listeners.forEach((fn) => { - fn(region); + fn(merged); }); } @@ -87,7 +97,10 @@ function getFallbackGeoRegion(): UserGeoRegion { const existing = getStoredUserGeoRegion(); if ( existing && - (existing.country || existing.phoneCode || existing.countryCode || existing.city) + (existing.country || + existing.phoneCode || + existing.countryCode || + existing.city) ) { console.log( "[GEO_BRIDGE_LOG] 📦 Preserving existing stored user geo region:", @@ -150,12 +163,14 @@ function fetchFlutterBridgeGeoRegion(): Promise { JSON.stringify(event), ); - const data = (event.data || (event as any).payload) as { - ip?: string; - country?: string; - country_code?: string; - city?: string; - } | undefined; + const data = (event.data || (event as any).payload) as + | { + ip?: string; + country?: string; + country_code?: string; + city?: string; + } + | undefined; if ( event.success && @@ -241,12 +256,12 @@ function fetchFlutterBridgeGeoRegion(): Promise { * Never performs direct HTTP requests. */ export function getUserGeoRegion(force = false): Promise { - // If !force: Check cachedRegion or getStoredUserGeoRegion(). If present, return it immediately. + // If !force: Check cachedRegion or getStoredUserGeoRegion(). If present and has location, return it immediately. if (!force) { const existing = cachedRegion || getStoredUserGeoRegion(); if ( existing && - (existing.city || existing.country || existing.phoneCode || existing.countryCode) + (existing.country || existing.countryCode || existing.city) ) { console.log( "[GEO_BRIDGE_LOG] 📦 Returning cached/stored user geo region:", @@ -273,7 +288,9 @@ export function getUserGeoRegion(force = false): Promise { console.log( "[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge action 'get_location'", ); - geoRegionPromise = fetchFlutterBridgeGeoRegion(); + geoRegionPromise = fetchFlutterBridgeGeoRegion().finally(() => { + geoRegionPromise = null; + }); } else { console.log( "[GEO_BRIDGE_LOG] 🌐 Standard browser / non-Flutter environment: resolving from cached storage or default fallback (no HTTP calls)",