diff --git a/app/(app)/create/components/FinalReviewChipEditModal.tsx b/app/(app)/create/components/FinalReviewChipEditModal.tsx index 2367b15..99d3d90 100644 --- a/app/(app)/create/components/FinalReviewChipEditModal.tsx +++ b/app/(app)/create/components/FinalReviewChipEditModal.tsx @@ -1078,6 +1078,7 @@ export function FinalReviewChipEditModal({ patch("stepByStepInstructions", v)} disabled={readOnly} /> - patch("consensusLevel", next)} - formatValue={(v) => `${v}%`} - decrementAriaLabel="Decrease consensus level" - incrementAriaLabel="Increase consensus level" - disabled={readOnly} - /> + {value.consensusLevel !== undefined ? ( + patch("consensusLevel", next)} + formatValue={(v) => `${v}%`} + decrementAriaLabel="Decrease consensus level" + incrementAriaLabel="Increase consensus level" + disabled={readOnly} + /> + ) : null} ); diff --git a/app/components/type/TextBlock/TextBlock.view.tsx b/app/components/type/TextBlock/TextBlock.view.tsx index dbbe4a3..f213787 100644 --- a/app/components/type/TextBlock/TextBlock.view.tsx +++ b/app/components/type/TextBlock/TextBlock.view.tsx @@ -94,9 +94,10 @@ function TextBlockView({ >

{title}

+ {body.trim().length > 0 ? : null} {hasRows ? rows!.map((row, i) => ) - : body.trim().length > 0 && } + : null}
); diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts index 9417974..42a93a9 100644 --- a/lib/create/buildPublishPayload.ts +++ b/lib/create/buildPublishPayload.ts @@ -16,7 +16,10 @@ import { publishedMethodDisplayLabel, } from "./finalReviewChipPresets"; import { isDocumentEntry } from "./documentEntryGuards"; -import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections"; +import { + replaceMethodSectionsWithMethodSelections, + withoutUnpublishedDecisionConsensus, +} from "./ruleSectionsFromMethodSelections"; import { templateCategoryToGroupKey } from "./templateReviewMapping"; export { isDocumentEntry } from "./documentEntryGuards"; @@ -78,21 +81,25 @@ export type PublishedMethodSelections = { id: string; label: string; sections: CommunicationMethodDetailEntry; + supportText?: string; }>; membership?: Array<{ id: string; label: string; sections: MembershipMethodDetailEntry; + supportText?: string; }>; decisionApproaches?: Array<{ id: string; label: string; sections: DecisionApproachDetailEntry; + supportText?: string; }>; conflictManagement?: Array<{ id: string; label: string; sections: ConflictManagementDetailEntry; + supportText?: string; }>; }; @@ -247,6 +254,14 @@ function pickMethodIds( return derived; } +function publishedRowSupportText( + id: string, + meta: CreateFlowState["customMethodCardMetaById"], +): string | undefined { + const t = meta?.[id]?.supportText?.trim(); + return t && t.length > 0 ? t : undefined; +} + /** * Merge `selected*MethodIds` with any saved `{group}MethodDetailsById` * overrides authored on the final-review screen. Preset defaults from the @@ -270,6 +285,10 @@ export function buildMethodSelectionsForDocument( out.communication = commIds.map((id) => { const preset = communicationPresetFor(id); const override = state.communicationMethodDetailsById?.[id]; + const supportText = publishedRowSupportText( + id, + state.customMethodCardMetaById, + ); return { id, label: publishedMethodDisplayLabel( @@ -278,6 +297,7 @@ export function buildMethodSelectionsForDocument( state.customMethodCardMetaById, ), sections: override ? { ...preset, ...override } : preset, + ...(supportText ? { supportText } : {}), }; }); } @@ -290,6 +310,10 @@ export function buildMethodSelectionsForDocument( out.membership = memIds.map((id) => { const preset = membershipPresetFor(id); const override = state.membershipMethodDetailsById?.[id]; + const supportText = publishedRowSupportText( + id, + state.customMethodCardMetaById, + ); return { id, label: publishedMethodDisplayLabel( @@ -298,6 +322,7 @@ export function buildMethodSelectionsForDocument( state.customMethodCardMetaById, ), sections: override ? { ...preset, ...override } : preset, + ...(supportText ? { supportText } : {}), }; }); } @@ -310,6 +335,11 @@ export function buildMethodSelectionsForDocument( out.decisionApproaches = daIds.map((id) => { const preset = decisionApproachPresetFor(id); const override = state.decisionApproachDetailsById?.[id]; + const supportText = publishedRowSupportText( + id, + state.customMethodCardMetaById, + ); + const merged = override ? { ...preset, ...override } : preset; return { id, label: publishedMethodDisplayLabel( @@ -317,7 +347,11 @@ export function buildMethodSelectionsForDocument( id, state.customMethodCardMetaById, ), - sections: override ? { ...preset, ...override } : preset, + sections: withoutUnpublishedDecisionConsensus( + { ...merged }, + state.customMethodCardFieldBlocksById?.[id], + ) as DecisionApproachDetailEntry, + ...(supportText ? { supportText } : {}), }; }); } @@ -330,6 +364,10 @@ export function buildMethodSelectionsForDocument( out.conflictManagement = cmIds.map((id) => { const preset = conflictManagementPresetFor(id); const override = state.conflictManagementDetailsById?.[id]; + const supportText = publishedRowSupportText( + id, + state.customMethodCardMetaById, + ); return { id, label: publishedMethodDisplayLabel( @@ -338,6 +376,7 @@ export function buildMethodSelectionsForDocument( state.customMethodCardMetaById, ), sections: override ? { ...preset, ...override } : preset, + ...(supportText ? { supportText } : {}), }; }); } diff --git a/lib/create/finalReviewChipPresets.ts b/lib/create/finalReviewChipPresets.ts index ab1bd8f..48c90f3 100644 --- a/lib/create/finalReviewChipPresets.ts +++ b/lib/create/finalReviewChipPresets.ts @@ -106,7 +106,7 @@ export function membershipPresetFor(id: string): MembershipMethodDetailEntry { }; } -/** Default consensus level used when presets omit a value (see DecisionApproachesScreen). */ +/** Default consensus level used when a **catalog** preset omits a value. */ export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75; export function decisionApproachPresetFor( @@ -114,19 +114,22 @@ export function decisionApproachPresetFor( ): DecisionApproachDetailEntry { const method = findMethod(decisionApproachesMessages, id); const s = method?.sections ?? {}; - return { + const entry: DecisionApproachDetailEntry = { corePrinciple: asString(s.corePrinciple), applicableScope: asStringArray(s.applicableScope), selectedApplicableScope: [], stepByStepInstructions: asString(s.stepByStepInstructions), - consensusLevel: asNumberClamped( + objectionsDeadlocks: asString(s.objectionsDeadlocks), + }; + if (method) { + entry.consensusLevel = asNumberClamped( s.consensusLevel, 0, 100, DECISION_CONSENSUS_LEVEL_DEFAULT, - ), - objectionsDeadlocks: asString(s.objectionsDeadlocks), - }; + ); + } + return entry; } export function conflictManagementPresetFor( diff --git a/lib/create/methodCardWizardPrefill.ts b/lib/create/methodCardWizardPrefill.ts index d81cf4f..278e881 100644 --- a/lib/create/methodCardWizardPrefill.ts +++ b/lib/create/methodCardWizardPrefill.ts @@ -195,12 +195,19 @@ function mapFacetPrefillToWizardFieldBlocks( prefill.headings.stepByStepInstructions, prefill.draft.stepByStepInstructions, ), - { + ); + if ( + typeof prefill.draft.consensusLevel === "number" && + facetPrefillHasContent(prefill) + ) { + blocks.push({ kind: "proportion", id: "facet-consensusLevel", blockTitle: prefill.headings.consensusLevel, defaultPercent: clampPercent(prefill.draft.consensusLevel), - }, + }); + } + blocks.push( textBlock( "facet-objectionsDeadlocks", prefill.headings.objectionsDeadlocks, diff --git a/lib/create/publishedDocumentToCreateFlowState.ts b/lib/create/publishedDocumentToCreateFlowState.ts index d2d6ea9..35cbb4e 100644 --- a/lib/create/publishedDocumentToCreateFlowState.ts +++ b/lib/create/publishedDocumentToCreateFlowState.ts @@ -22,6 +22,7 @@ function customMethodCardMetaFromPublishedSelections( | Array<{ id: string; label: string; + supportText?: string; }> | undefined, ) => { @@ -32,7 +33,9 @@ function customMethodCardMetaFromPublishedSelections( if (methodLabelFor(groupKey, id).length > 0) continue; const label = typeof row.label === "string" ? row.label.trim() : ""; if (!label) continue; - meta[id] = { label, supportText: "" }; + const supportText = + typeof row.supportText === "string" ? row.supportText : ""; + meta[id] = { label, supportText }; } }; absorb("communication", ms.communication); diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts index a0d2a33..8c2ddf5 100644 --- a/lib/create/ruleSectionsFromMethodSelections.ts +++ b/lib/create/ruleSectionsFromMethodSelections.ts @@ -78,6 +78,8 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks( export type CommunityRuleEntryFromChipOptions = { consensusLevelKey?: string; customFieldBlocks?: CustomMethodCardFieldBlock[]; + /** Wizard step-2 policy description (`customMethodCardMetaById.supportText`). */ + supportText?: string; }; /** Canonical `categoryName` strings for method groups in published documents. */ @@ -195,8 +197,64 @@ export function communityRuleEntryFromMethodChip( ? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks) : []; const blocks = [...presetBlocks, ...wizardBlocks]; - if (blocks.length === 0) return null; - return { title, body: "", blocks }; + const description = nonEmptyTrimmed(options?.supportText); + if (blocks.length === 0) { + if (!description) return null; + return { title, body: description }; + } + return { + title, + body: description ?? "", + blocks, + }; +} + +function decisionApproachHasPublishableFacetCopy( + sections: Record, +): boolean { + return Boolean( + nonEmptyTrimmed(sections.corePrinciple) || + nonEmptyTrimmed(sections.stepByStepInstructions) || + nonEmptyTrimmed(sections.objectionsDeadlocks) || + formatScopePayload(sections.applicableScope) || + formatScopePayload(sections.selectedApplicableScope), + ); +} + +/** + * Catalog methods publish their consensus figure. User-authored custom cards + * often seed `75` with empty facet copy — skip that unless the author actually + * filled decision sections. Wizard field blocks (including proportion) are the + * source of truth when present. + */ +function shouldPublishDecisionConsensusLevel( + sections: Record, + customFieldBlocks?: CustomMethodCardFieldBlock[], +): boolean { + if ( + typeof sections.consensusLevel !== "number" || + Number.isNaN(sections.consensusLevel) + ) { + return false; + } + if (customFieldBlocks && customFieldBlocks.length > 0) { + return false; + } + return decisionApproachHasPublishableFacetCopy(sections); +} + +/** Drop seeded / wizard-superseded `consensusLevel` before publish or hydrate. */ +export function withoutUnpublishedDecisionConsensus( + sections: Record, + customFieldBlocks?: CustomMethodCardFieldBlock[], +): Record { + if (shouldPublishDecisionConsensusLevel(sections, customFieldBlocks)) { + return sections; + } + if (!("consensusLevel" in sections)) return sections; + const next = { ...sections }; + delete next.consensusLevel; + return next; } export function sectionFromCommunication( @@ -209,6 +267,7 @@ export function sectionFromCommunication( const sec = m.sections as unknown as Record; const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS, { customFieldBlocks: customFieldBlocksById?.[m.id], + supportText: m.supportText, }); if (e) entries.push(e); } @@ -227,6 +286,7 @@ export function sectionFromMembership( const sec = m.sections as unknown as Record; const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS, { customFieldBlocks: customFieldBlocksById?.[m.id], + supportText: m.supportText, }); if (e) entries.push(e); } @@ -254,10 +314,19 @@ export function sectionFromDecision( ); if (scope) merged.applicableScope = scope; delete merged.selectedApplicableScope; - const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, { - consensusLevelKey: "consensusLevel", - customFieldBlocks: customFieldBlocksById?.[m.id], - }); + const e = communityRuleEntryFromMethodChip( + m.label, + withoutUnpublishedDecisionConsensus( + merged, + customFieldBlocksById?.[m.id], + ), + DEC_LABELS, + { + consensusLevelKey: "consensusLevel", + customFieldBlocks: customFieldBlocksById?.[m.id], + supportText: m.supportText, + }, + ); if (e) entries.push(e); } return entries.length > 0 @@ -281,6 +350,7 @@ export function sectionFromConflict( delete merged.selectedApplicableScope; const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS, { customFieldBlocks: customFieldBlocksById?.[m.id], + supportText: m.supportText, }); if (e) entries.push(e); } diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index 42d8ef6..0284b8e 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -54,7 +54,7 @@ const decisionApproachDetailEntrySchema = z.object({ applicableScope: z.array(z.string().max(2000)).max(50), selectedApplicableScope: z.array(z.string().max(2000)).max(50), stepByStepInstructions: z.string().max(8000), - consensusLevel: z.number().int().min(0).max(100), + consensusLevel: z.number().int().min(0).max(100).optional(), objectionsDeadlocks: z.string().max(8000), }); diff --git a/tests/components/CommunityRule.test.tsx b/tests/components/CommunityRule.test.tsx index 422dbf7..b6e9c26 100644 --- a/tests/components/CommunityRule.test.tsx +++ b/tests/components/CommunityRule.test.tsx @@ -50,4 +50,26 @@ describe("CommunityRule", () => { ); expect(screen.getByText("How proposals pass")).toBeInTheDocument(); }); + + it("renders entry body together with labeled blocks", () => { + render( + , + ); + expect(screen.getByText("Anyone can start a thread.")).toBeInTheDocument(); + expect(screen.getByText("Quorum")).toBeInTheDocument(); + expect(screen.getByText("60%")).toBeInTheDocument(); + }); }); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 22b6c47..77d818f 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -519,6 +519,25 @@ describe("FinalReviewScreen — chip detail modal", () => { ).not.toBeInTheDocument(); }); + it("closes the chip edit modal when Back is pressed", async () => { + render( + {}} + initial={{ + title: "Oak Park Commons", + selectedCommunicationMethodIds: ["signal"], + }} + />, + ); + + fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "Back" })); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + }); /** diff --git a/tests/components/TextBlock.test.tsx b/tests/components/TextBlock.test.tsx index fa52913..462ad45 100644 --- a/tests/components/TextBlock.test.tsx +++ b/tests/components/TextBlock.test.tsx @@ -45,4 +45,17 @@ describe("TextBlock", () => { "/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee", ); }); + + it("renders body paragraphs above labeled rows when both are set", () => { + render( + , + ); + expect(screen.getByText("Anyone can start a thread.")).toBeInTheDocument(); + expect(screen.getByText("Quorum")).toBeInTheDocument(); + expect(screen.getByText("60%")).toBeInTheDocument(); + }); }); diff --git a/tests/unit/buildPublishPayload.test.ts b/tests/unit/buildPublishPayload.test.ts index ddc6cd2..389d1d9 100644 --- a/tests/unit/buildPublishPayload.test.ts +++ b/tests/unit/buildPublishPayload.test.ts @@ -249,6 +249,55 @@ describe("buildPublishPayload — methodSelections", () => { expect(entry.sections.corePrinciple.length).toBeGreaterThan(0); }); + it("does not seed 75% consensus on a custom decision-making card", () => { + const customId = "00000000-0000-4000-8000-000000000170"; + const r = buildPublishPayload({ + title: "T", + selectedDecisionApproachIds: [customId], + customMethodCardMetaById: { + [customId]: { + label: "Forum proposals", + supportText: "Anyone can start a thread.", + }, + }, + decisionApproachDetailsById: { + [customId]: { + corePrinciple: "", + applicableScope: [], + selectedApplicableScope: [], + stepByStepInstructions: "", + consensusLevel: 75, + objectionsDeadlocks: "", + }, + }, + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + const ms = r.document.methodSelections as { + decisionApproaches?: Array<{ + supportText?: string; + sections: { consensusLevel?: number }; + }>; + }; + expect(ms.decisionApproaches?.[0]?.supportText).toBe( + "Anyone can start a thread.", + ); + expect(ms.decisionApproaches?.[0]?.sections.consensusLevel).toBeUndefined(); + }); + + it("keeps catalog decision consensus in methodSelections", () => { + const r = buildPublishPayload({ + title: "T", + selectedDecisionApproachIds: ["lazy-consensus"], + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + const ms = r.document.methodSelections as { + decisionApproaches?: Array<{ sections: { consensusLevel?: number } }>; + }; + expect(ms.decisionApproaches?.[0]?.sections.consensusLevel).toBe(100); + }); + it("merges override on top of preset for the selected method", () => { const r = buildPublishPayload({ title: "T", diff --git a/tests/unit/methodCardWizardPrefill.test.ts b/tests/unit/methodCardWizardPrefill.test.ts index e75a32d..3f9c5ca 100644 --- a/tests/unit/methodCardWizardPrefill.test.ts +++ b/tests/unit/methodCardWizardPrefill.test.ts @@ -3,6 +3,7 @@ import { buildMethodCardWizardInitialValues, coreValueDetailsFromWizardFieldBlocks, facetDetailsToWizardFieldBlocks, + overlayFacetPrefillValues, } from "../../lib/create/methodCardWizardPrefill"; import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks"; @@ -219,6 +220,49 @@ describe("facetDetailsToWizardFieldBlocks", () => { }); }); + it("omits a consensus proportion when the draft has no consensus level", () => { + const blocksOut = facetDetailsToWizardFieldBlocks({ + group: "decisionApproaches", + draft: { + corePrinciple: "Momentum.", + applicableScope: [], + selectedApplicableScope: [], + stepByStepInstructions: "Post a deadline.", + objectionsDeadlocks: "", + }, + headings: { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + stepByStepInstructions: "Step-by-Step Instructions", + consensusLevel: "Consensus Level", + objectionsDeadlocks: "Objections & Deadlocks", + }, + }); + expect(blocksOut.find((b) => b.kind === "proportion")).toBeUndefined(); + }); + + it("does not inject a seeded 75% proportion onto empty custom drafts", () => { + const next = overlayFacetPrefillValues([], { + group: "decisionApproaches", + draft: { + corePrinciple: "", + applicableScope: [], + selectedApplicableScope: [], + stepByStepInstructions: "", + consensusLevel: 75, + objectionsDeadlocks: "", + }, + headings: { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + stepByStepInstructions: "Step-by-Step Instructions", + consensusLevel: "Consensus Level", + objectionsDeadlocks: "Objections & Deadlocks", + }, + }); + expect(next).toEqual([]); + }); + it("maps core value meaning and signals onto text blocks", () => { expect( facetDetailsToWizardFieldBlocks({ diff --git a/tests/unit/publishedDocumentToCreateFlowState.test.ts b/tests/unit/publishedDocumentToCreateFlowState.test.ts index 5adfa28..e80e0a1 100644 --- a/tests/unit/publishedDocumentToCreateFlowState.test.ts +++ b/tests/unit/publishedDocumentToCreateFlowState.test.ts @@ -314,6 +314,37 @@ describe("createFlowStateFromPublishedRule", () => { }); }); + it("hydrates wizard supportText from published methodSelections", () => { + const customId = "b7c0a9f3-0000-4000-8000-000000000002"; + const partial = createFlowStateFromPublishedRule({ + id: "rule-custom-desc", + title: "C", + summary: "", + document: { + methodSelections: { + decisionApproaches: [ + { + id: customId, + label: "Forum proposals", + supportText: "Anyone can start a thread.", + sections: { + corePrinciple: "", + applicableScope: [], + selectedApplicableScope: [], + stepByStepInstructions: "", + objectionsDeadlocks: "", + }, + }, + ], + }, + }, + }); + expect(partial.customMethodCardMetaById?.[customId]).toEqual({ + label: "Forum proposals", + supportText: "Anyone can start a thread.", + }); + }); + it("sets sections to [] even when methodSelections is missing (edit hydrate)", () => { const partial = createFlowStateFromPublishedRule({ id: "rule-2", diff --git a/tests/unit/publishedDocumentToDisplaySections.test.ts b/tests/unit/publishedDocumentToDisplaySections.test.ts index dd9dc28..9f83ca6 100644 --- a/tests/unit/publishedDocumentToDisplaySections.test.ts +++ b/tests/unit/publishedDocumentToDisplaySections.test.ts @@ -248,4 +248,35 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => { }, ]); }); + + it("shows custom decision description without a seeded 75% consensus row", () => { + const customId = "00000000-0000-4000-8000-000000000170"; + const out = parsePublishedDocumentForCommunityRuleDisplay({ + sections: [], + methodSelections: { + decisionApproaches: [ + { + id: customId, + label: "Forum proposals", + supportText: "Anyone can start a thread.", + sections: { + corePrinciple: "", + applicableScope: [], + selectedApplicableScope: [], + stepByStepInstructions: "", + consensusLevel: 75, + objectionsDeadlocks: "", + }, + }, + ], + }, + }); + const decision = out.find((s) => s.categoryName === "Decision-making"); + expect(decision?.entries).toEqual([ + { + title: "Forum proposals", + body: "Anyone can start a thread.", + }, + ]); + }); }); diff --git a/tests/unit/ruleSectionsFromMethodSelections.test.ts b/tests/unit/ruleSectionsFromMethodSelections.test.ts new file mode 100644 index 0000000..ec97bf2 --- /dev/null +++ b/tests/unit/ruleSectionsFromMethodSelections.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { + communityRuleEntryFromMethodChip, + sectionFromDecision, + withoutUnpublishedDecisionConsensus, +} from "../../lib/create/ruleSectionsFromMethodSelections"; + +const emptyDecisionSections = { + corePrinciple: "", + applicableScope: [] as string[], + selectedApplicableScope: [] as string[], + stepByStepInstructions: "", + consensusLevel: 75, + objectionsDeadlocks: "", +}; + +describe("withoutUnpublishedDecisionConsensus", () => { + it("keeps catalog consensus when facet copy is present", () => { + const next = withoutUnpublishedDecisionConsensus({ + corePrinciple: "Momentum.", + consensusLevel: 100, + }); + expect(next.consensusLevel).toBe(100); + }); + + it("drops seeded consensus on empty custom facet copy", () => { + const next = withoutUnpublishedDecisionConsensus({ ...emptyDecisionSections }); + expect(next.consensusLevel).toBeUndefined(); + }); + + it("drops keyed consensus when wizard field blocks exist", () => { + const next = withoutUnpublishedDecisionConsensus( + { corePrinciple: "Momentum.", consensusLevel: 75 }, + [ + { + kind: "proportion", + id: "p1", + blockTitle: "Quorum", + defaultPercent: 60, + }, + ], + ); + expect(next.consensusLevel).toBeUndefined(); + }); +}); + +describe("communityRuleEntryFromMethodChip", () => { + it("publishes supportText as body when there are no labeled blocks", () => { + expect( + communityRuleEntryFromMethodChip( + "Our process", + { corePrinciple: "" }, + { corePrinciple: "Core Principle" }, + { supportText: " Members propose in the forum. " }, + ), + ).toEqual({ + title: "Our process", + body: "Members propose in the forum.", + }); + }); + + it("keeps supportText alongside wizard blocks", () => { + const entry = communityRuleEntryFromMethodChip( + "Our process", + { corePrinciple: "" }, + { corePrinciple: "Core Principle" }, + { + supportText: "How we decide.", + customFieldBlocks: [ + { + kind: "text", + id: "b1", + blockTitle: "Notes", + placeholderText: "Post in #governance.", + }, + ], + }, + ); + expect(entry).toEqual({ + title: "Our process", + body: "How we decide.", + blocks: [{ label: "Notes", body: "Post in #governance." }], + }); + }); +}); + +describe("sectionFromDecision", () => { + it("omits seeded 75% and shows the policy description on a custom card", () => { + const section = sectionFromDecision([ + { + id: "00000000-0000-4000-8000-000000000001", + label: "Forum proposals", + supportText: "Anyone can start a thread; silence after a week is consent.", + sections: { ...emptyDecisionSections }, + }, + ]); + expect(section?.categoryName).toBe("Decision-making"); + expect(section?.entries).toEqual([ + { + title: "Forum proposals", + body: "Anyone can start a thread; silence after a week is consent.", + }, + ]); + }); + + it("still emits catalog consensus when facet copy is present", () => { + const section = sectionFromDecision([ + { + id: "lazy-consensus", + label: "Lazy Consensus", + sections: { + corePrinciple: "Silence is consent.", + applicableScope: [], + selectedApplicableScope: [], + stepByStepInstructions: "Post a deadline.", + consensusLevel: 100, + objectionsDeadlocks: "Any member can block.", + }, + }, + ]); + const blocks = section?.entries[0]?.blocks ?? []; + expect(blocks).toContainEqual({ + label: "Consensus Level", + body: "100%", + }); + }); + + it("does not duplicate consensus when a wizard proportion block exists", () => { + const section = sectionFromDecision( + [ + { + id: "00000000-0000-4000-8000-000000000002", + label: "Custom vote", + supportText: "We vote in person.", + sections: { + ...emptyDecisionSections, + consensusLevel: 75, + }, + }, + ], + { + "00000000-0000-4000-8000-000000000002": [ + { + kind: "proportion", + id: "q", + blockTitle: "Quorum", + defaultPercent: 60, + }, + ], + }, + ); + const blocks = section?.entries[0]?.blocks ?? []; + expect(blocks).toEqual([{ label: "Quorum", body: "60%" }]); + expect(section?.entries[0]?.body).toBe("We vote in person."); + }); +});