Browse Source

fix(assessments): robust options parsing for Cattell and Glasser

master
mortezaei 2 days ago
parent
commit
8f6934468f
  1. 81
      src/app/questions-list/[slug]/question-detail-client.test.tsx
  2. 114
      src/app/questions-list/[slug]/question-detail-client.tsx
  3. 151
      src/components/Componentes/question-birthplace.tsx
  4. 41
      src/lib/geo-region.ts

81
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", () => {

114
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 [];
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 questionsList.map((q) => ({
id: q.question_number,
text: q.text,
options: q.options || [],
}));
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 [];
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 questionsList.map((q) => ({
id: q.question_number,
text: q.text,
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: q.options || [],
}));
}, [glasserQuery.data]);
options,
};
});
}, [glasserQuery.data, locale]);

151
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,7 +269,8 @@ export function QuestionBirthplace({
const lastInternalAnswerRef = useRef<BirthplaceValue | string | null>(null);
const updateAnswers = (country: string, city: string) => {
const updateAnswers = useCallback(
(country: string, city: string) => {
const cleanCountry = country?.trim() || "";
const cleanCity = city?.trim() || "";
const payload =
@ -254,7 +279,53 @@ export function QuestionBirthplace({
: 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);

41
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<UserGeoRegion> {
JSON.stringify(event),
);
const data = (event.data || (event as any).payload) as {
const data = (event.data || (event as any).payload) as
| {
ip?: string;
country?: string;
country_code?: string;
city?: string;
} | undefined;
}
| undefined;
if (
event.success &&
@ -241,12 +256,12 @@ function fetchFlutterBridgeGeoRegion(): Promise<UserGeoRegion> {
* Never performs direct HTTP requests.
*/
export function getUserGeoRegion(force = false): Promise<UserGeoRegion> {
// 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<UserGeoRegion> {
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)",

Loading…
Cancel
Save