Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions app/(localized)/[locale]/personality/[type]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -1351,6 +1350,10 @@ function PersonalityComparisonPage({
</section>
)}

{hasDetailedCrossTypeSections ? (
<CrossTypeDetailedSections sections={comparison.crossTypeSections} />
) : null}

<ComparisonTemplateCardsSection
id="comparison-misread-risks"
testId="personality-comparison-misread-risks"
Expand Down
66 changes: 66 additions & 0 deletions components/personality/CrossTypeDetailedSections.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { PersonalityCrossTypeSectionViewModel } from "@/lib/cms/personality";

export function CrossTypeDetailedSections({
sections,
}: {
sections: PersonalityCrossTypeSectionViewModel[];
}) {
if (sections.length === 0) {
return null;
}

return (
<div className="space-y-6" data-testid="personality-cross-type-detailed-sections">
{sections.map((section) => (
<section
key={section.id}
id={`comparison-${section.id.replaceAll("_", "-")}`}
className="rounded-[1.25rem] border border-[rgba(16,24,40,0.10)] bg-white p-5 shadow-[0_12px_35px_rgba(15,23,42,0.05)]"
data-testid={`personality-cross-type-section-${section.id}`}
data-authority-source="comparison_public_projection_v1"
>
<h2 className="m-0 text-xl font-semibold text-[var(--fm-text)]">{section.title}</h2>

{section.body.length > 0 ? (
<div className="mt-3 space-y-3">
{section.body.map((paragraph, index) => (
<p
key={`${section.id}-body-${index}`}
className="m-0 text-sm leading-7 text-[var(--fm-text-muted)]"
>
{paragraph}
</p>
))}
</div>
) : null}

{section.groups.length > 0 ? (
<div className="mt-5 grid gap-4 md:grid-cols-2">
{section.groups.map((group, groupIndex) => (
<article
key={`${section.id}-group-${groupIndex}`}
className="rounded-xl border border-[rgba(16,24,40,0.08)] bg-[var(--fm-surface-muted)] p-4"
>
<h3 className="m-0 text-base font-semibold text-[var(--fm-text)]">{group.title}</h3>
<ul className="mt-3 space-y-2 pl-5 text-sm leading-7 text-[var(--fm-text-muted)]">
{group.items.map((item, itemIndex) => (
<li key={`${section.id}-group-${groupIndex}-item-${itemIndex}`}>{item}</li>
))}
</ul>
</article>
))}
</div>
) : null}

{section.items.length > 0 ? (
<ul className="mt-4 space-y-2 pl-5 text-sm leading-7 text-[var(--fm-text-muted)]">
{section.items.map((item, index) => (
<li key={`${section.id}-item-${index}`}>{item}</li>
))}
</ul>
) : null}
</section>
))}
</div>
);
}
27 changes: 25 additions & 2 deletions lib/cms/personality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ type CmsPersonalityCrossTypeSectionApiRecord = {
title?: string | null;
body?: unknown;
rows?: unknown;
groups?: unknown;
items?: unknown;
};

type CmsPersonalityCrossTypeFaqApiRecord = {
Expand Down Expand Up @@ -585,6 +587,11 @@ export type PersonalityCrossTypeSectionViewModel = {
title: string;
body: string[];
rows: Array<Record<string, string>>;
groups: Array<{
title: string;
items: string[];
}>;
items: string[];
};

export type PersonalityCrossTypeFaqViewModel = {
Expand Down Expand Up @@ -1022,12 +1029,28 @@ function normalizeCrossTypeSection(section: CmsPersonalityCrossTypeSectionApiRec
})
.filter((row): row is Record<string, string> => 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 {
Expand Down
10 changes: 10 additions & 0 deletions tests/contracts/helpers/currentPrScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 98 additions & 0 deletions tests/contracts/personality-comparison-pages.contract.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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: "下一步阅读",
Expand Down Expand Up @@ -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(<CrossTypeDetailedSections sections={sections} />);
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");

Expand Down Expand Up @@ -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<Record<string, string>>");
expect(adapterSource).toContain("groups:");
expect(adapterSource).toContain("items: string[]");
expect(adapterSource).toContain("normalizeAnswerSurface(response.answer_surface_v1");

expect(sitemapSource).toContain("buildPersonalityComparisonPathsFromAuthority");
Expand Down