diff --git a/app/(localized)/[locale]/personality/[type]/page.tsx b/app/(localized)/[locale]/personality/[type]/page.tsx index 422317dae..acad6183a 100644 --- a/app/(localized)/[locale]/personality/[type]/page.tsx +++ b/app/(localized)/[locale]/personality/[type]/page.tsx @@ -8,6 +8,7 @@ import { Breadcrumb } from "@/components/breadcrumb/Breadcrumb"; import { TrackedEntryCtaLink } from "@/components/analytics/TrackedEntryCtaLink"; import { AnswerSurfaceSection } from "@/components/content/AnswerSurfaceSection"; import { MbtiSceneEntrySection } from "@/components/content/MbtiSceneEntrySection"; +import { CrossTypeDetailedSections } from "@/components/personality/CrossTypeDetailedSections"; import { JsonLd } from "@/components/seo/JsonLd"; import { buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -728,12 +729,7 @@ function buildComparisonQuickJudgmentRows(comparison: PersonalityComparisonViewM return structuredRows; } - return comparison.crossTypeSections.map((section) => ({ - key: `cross-${section.id}`, - cue: section.title, - left: section.body.join(" "), - right: "", - })); + return []; } return comparison.comparisonBlocks @@ -1246,13 +1242,16 @@ function PersonalityComparisonPage({ const turbulentLabel = comparison.variants?.t.runtimeTypeCode ?? comparison.rightType ?? ""; const quickAnswerBody = comparisonQuickAnswerBody(comparison); const quickJudgmentRows = buildComparisonQuickJudgmentRows(comparison); - const misreadCards = buildComparisonMisreadCards(comparison); - const scenarioCards = buildComparisonScenarioCards(comparison); + const hasDetailedCrossTypeSections = + isCrossTypeComparison(comparison) && + comparison.crossTypeSections.some((section) => section.groups.length > 0 || section.items.length > 0); + const misreadCards = hasDetailedCrossTypeSections ? [] : buildComparisonMisreadCards(comparison); + const scenarioCards = hasDetailedCrossTypeSections ? [] : buildComparisonScenarioCards(comparison); const renderedComparisonSections = renderPersonalitySections(comparison.sections, locale); const comparisonFaqItems = buildVisibleComparisonFaqItems(comparison); const secondaryAnswerSurface = buildComparisonSecondaryAnswerSurface(comparison); const nextStepBlocks = comparison.answerSurface?.nextStepBlocks ?? []; - const nextReadingSections = isCrossTypeComparison(comparison) + const nextReadingSections = isCrossTypeComparison(comparison) && !hasDetailedCrossTypeSections ? comparison.crossTypeSections.filter( (section) => section.id === "next_reading" && section.body.length > 0 ) @@ -1351,6 +1350,10 @@ function PersonalityComparisonPage({ )} + {hasDetailedCrossTypeSections ? ( + + ) : null} + + {sections.map((section) => ( + + {section.title} + + {section.body.length > 0 ? ( + + {section.body.map((paragraph, index) => ( + + {paragraph} + + ))} + + ) : null} + + {section.groups.length > 0 ? ( + + {section.groups.map((group, groupIndex) => ( + + {group.title} + + {group.items.map((item, itemIndex) => ( + {item} + ))} + + + ))} + + ) : null} + + {section.items.length > 0 ? ( + + {section.items.map((item, index) => ( + {item} + ))} + + ) : null} + + ))} + + ); +} diff --git a/lib/cms/personality.ts b/lib/cms/personality.ts index 878992498..1ea8519dd 100644 --- a/lib/cms/personality.ts +++ b/lib/cms/personality.ts @@ -156,6 +156,8 @@ type CmsPersonalityCrossTypeSectionApiRecord = { title?: string | null; body?: unknown; rows?: unknown; + groups?: unknown; + items?: unknown; }; type CmsPersonalityCrossTypeFaqApiRecord = { @@ -585,6 +587,11 @@ export type PersonalityCrossTypeSectionViewModel = { title: string; body: string[]; rows: Array>; + groups: Array<{ + title: string; + items: string[]; + }>; + items: string[]; }; export type PersonalityCrossTypeFaqViewModel = { @@ -1022,12 +1029,28 @@ function normalizeCrossTypeSection(section: CmsPersonalityCrossTypeSectionApiRec }) .filter((row): row is Record => row !== null) : []; + const groups = Array.isArray(section.groups) + ? section.groups + .map((group) => { + const record = asRecord(group); + if (!record) { + return null; + } + + const groupTitle = fallbackText(typeof record.title === "string" ? record.title : ""); + const groupItems = normalizeStringArray(record.items); + + return groupTitle && groupItems.length > 0 ? { title: groupTitle, items: groupItems } : null; + }) + .filter((group): group is { title: string; items: string[] } => group !== null) + : []; + const items = normalizeStringArray(section.items); - if (!id || !title || (body.length === 0 && rows.length === 0)) { + if (!id || !title || (body.length === 0 && rows.length === 0 && groups.length === 0 && items.length === 0)) { return null; } - return { id, title, body, rows }; + return { id, title, body, rows, groups, items }; } function normalizeCrossTypeFaq(item: CmsPersonalityCrossTypeFaqApiRecord): PersonalityCrossTypeFaqViewModel | null { diff --git a/tests/contracts/helpers/currentPrScope.ts b/tests/contracts/helpers/currentPrScope.ts index 997e9dc8f..66cbf6d8b 100644 --- a/tests/contracts/helpers/currentPrScope.ts +++ b/tests/contracts/helpers/currentPrScope.ts @@ -6495,6 +6495,16 @@ export function isSeoCmsDraftPackageContract01AllowedFile(file: string): boolean } export function isPersonalityComparisonV1FromAssetsAllowedFile(file: string): boolean { + if (CURRENT_BRANCH === "codex/w1-mbti-comparison-detailed-renderer-repair") { + return new Set([ + "app/(localized)/[locale]/personality/[type]/page.tsx", + "components/personality/CrossTypeDetailedSections.tsx", + "lib/cms/personality.ts", + "tests/contracts/helpers/currentPrScope.ts", + "tests/contracts/personality-comparison-pages.contract.test.tsx", + ]).has(file); + } + if (CURRENT_BRANCH === "codex/mbti-index52-english-alternate-hold") { return new Set([ "app/(localized)/[locale]/personality/[type]/page.tsx", diff --git a/tests/contracts/personality-comparison-pages.contract.test.tsx b/tests/contracts/personality-comparison-pages.contract.test.tsx index 28d8e47b2..ec19f96bf 100644 --- a/tests/contracts/personality-comparison-pages.contract.test.tsx +++ b/tests/contracts/personality-comparison-pages.contract.test.tsx @@ -1,10 +1,13 @@ import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; +import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { CrossTypeDetailedSections } from "@/components/personality/CrossTypeDetailedSections"; import { getPersonalityComparisonBySlug, type CmsPersonalityProfileSummary, + type PersonalityCrossTypeSectionViewModel, } from "@/lib/cms/personality"; import { buildPersonalityComparisonSlugsFromProfiles, @@ -162,6 +165,27 @@ describe("PERSONALITY-COMPARISON-PAGES-01", () => { title: "工作方式差异", body: ["INTJ 更重视路径推进,INTP 更重视前提检验。"], }, + { + id: "stress_and_growth", + title: "压力与成长", + body: ["两种类型会从不同入口处理压力。"], + groups: [ + { + title: "INTJ 在压力下", + items: ["先收紧计划。", "再检查关键路径。"], + }, + { + title: "INTP 在压力下", + items: ["先重开假设。", "再寻找模型漏洞。"], + }, + ], + }, + { + id: "misconceptions", + title: "常见误解", + body: [], + items: ["安静不等于没有观点。", "开放不等于没有方向。"], + }, { id: "next_reading", title: "下一步阅读", @@ -209,12 +233,83 @@ describe("PERSONALITY-COMPARISON-PAGES-01", () => { title: "下一步阅读", body: ["继续查看 INTJ、INTP 的单独页面、A/T 页面和 MBTI 测试页。"], }); + expect(comparison?.crossTypeSections.find((section) => section.id === "stress_and_growth")?.groups).toEqual([ + { + title: "INTJ 在压力下", + items: ["先收紧计划。", "再检查关键路径。"], + }, + { + title: "INTP 在压力下", + items: ["先重开假设。", "再寻找模型漏洞。"], + }, + ]); + expect(comparison?.crossTypeSections.find((section) => section.id === "misconceptions")?.items).toEqual([ + "安静不等于没有观点。", + "开放不等于没有方向。", + ]); expect(comparison?.crossTypeFaq[0]?.question).toBe("INTJ 和 INTP 最大区别是什么?"); expect(comparison?.crossTypeInternalLinks[0]?.href).toBe("/zh/personality/intj"); expect(comparison?.claimBoundary).toBe("这是人格线索对比,不是诊断或职业结论。"); expect(comparison?.sourceRefs).toContain("mbti.cross_type_comparison.authority.v1"); }); + it("renders every detailed cross-type paragraph, group, and item exactly once", () => { + const sections: PersonalityCrossTypeSectionViewModel[] = [ + { + id: "quick_answer", + title: "Quick answer", + body: ["Quick answer paragraph one.", "Quick answer paragraph two."], + rows: [], + groups: [], + items: [], + }, + { + id: "shared_traits", + title: "Shared traits", + body: ["Shared traits paragraph."], + rows: [], + groups: [], + items: [], + }, + { + id: "core_difference", + title: "Core difference", + body: ["Core difference paragraph."], + rows: [], + groups: [], + items: [], + }, + { + id: "stress_and_growth", + title: "Stress and growth", + body: ["Stress context paragraph."], + rows: [], + groups: [ + { title: "First type under stress", items: ["First stress cue.", "First growth cue."] }, + { title: "Second type under stress", items: ["Second stress cue.", "Second growth cue."] }, + ], + items: [], + }, + { + id: "misconceptions", + title: "Misconceptions", + body: [], + rows: [], + groups: [], + items: ["First misconception.", "Second misconception."], + }, + ]; + const markup = renderToStaticMarkup(); + const expectedVisibleText = [ + ...sections.flatMap((section) => [section.title, ...section.body, ...section.items]), + ...sections.flatMap((section) => section.groups.flatMap((group) => [group.title, ...group.items])), + ]; + + for (const text of expectedVisibleText) { + expect(markup.split(`>${text}<`)).toHaveLength(2); + } + }); + it("keeps English comparison hreflang held until the backend returns English authority", () => { const pageSource = read("app/(localized)/[locale]/personality/[type]/page.tsx"); @@ -406,12 +501,15 @@ describe("PERSONALITY-COMPARISON-PAGES-01", () => { expect(pageSource).toContain("isCrossTypeComparison(comparison)"); expect(pageSource).toContain('data-testid="personality-cross-type-bases"'); expect(pageSource).toContain("CrossTypeInternalLinks"); + expect(pageSource).toContain("CrossTypeDetailedSections"); expect(adapterSource).toContain("/v0.5/personality/comparisons/"); expect(adapterSource).toContain("comparison_public_projection_v1"); expect(adapterSource).toContain('"mbti_cross_type"'); expect(adapterSource).toContain("crossTypeSections"); expect(adapterSource).toContain("rows: Array>"); + expect(adapterSource).toContain("groups:"); + expect(adapterSource).toContain("items: string[]"); expect(adapterSource).toContain("normalizeAnswerSurface(response.answer_surface_v1"); expect(sitemapSource).toContain("buildPersonalityComparisonPathsFromAuthority");
+ {paragraph} +