Import the remade decks on stable ids and recommend one card per job, so a saved draft still loads after a chip is renamed.

The real-group profiles stay as a test, so a change that moves a pick fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-11-04 14:22:54 -07:00
1 parent 96ca15d8e3
commit bcbc6f30d9
89 files changed
+36555 -2828

No files matched your search

+2 -4
View File
@@ -105,9 +105,7 @@ describe("RuleStack Component", () => {
expect(GOVERNANCE_TEMPLATE_CATALOG.length).toBeGreaterThan(
homeFeatured.length,
);
expect(
screen.queryByText("Solidarity Network"),
).not.toBeInTheDocument();
expect(screen.queryByText("Quadratic Governance")).not.toBeInTheDocument();
});
test("renders with custom className", async () => {
@@ -139,7 +137,7 @@ describe("RuleStack Component", () => {
).toBeInTheDocument();
expect(
screen.getByText(
/Voting cost is squared \(V²\), preventing a majority from steamrolling a passionate minority\./,
/Power is held by autonomous "cells\." A central hub acts as a switchboard for resources but cannot dictate cell activities\./,
),
).toBeInTheDocument();
});
+7 -9
View File
@@ -7,14 +7,12 @@ import { methodSectionsPinsForHydratedSelections } from "../../lib/create/publis
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
function coreValuePresetId(label: string): string {
const values = coreValuesMessages.values as Array<
string | { label: string }
>;
const idx = values.findIndex((v) => {
const l = typeof v === "string" ? v : v.label;
return l.toLowerCase() === label.toLowerCase();
});
return String(idx + 1);
const values = coreValuesMessages.values as Array<{ id: string; label: string }>;
const row = values.find(
(value) => value.label.toLowerCase() === label.toLowerCase(),
);
if (!row) throw new Error(`No core value named ${label}`);
return row.id;
}
describe("buildTemplateCustomizePrefill", () => {
@@ -76,7 +74,7 @@ describe("buildTemplateCustomizePrefill", () => {
const snapshot = prefill.coreValuesChipsSnapshot ?? [];
const selectedRows = snapshot.filter((r) => r.state === "selected");
expect(selectedRows.map((r) => r.label).sort()).toEqual([
"Community Care",
"Community care",
"Consensus",
]);
// Unmatched presets should still appear, as unselected, so the screen
@@ -20,19 +20,19 @@ describe("buildFinalReviewCategoriesFromState", () => {
// Same shape `buildTemplateCustomizePrefill` emits via methodSlugFromTitle.
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", "in-person-meetings"],
selectedMembershipMethodIds: ["open-access"],
selectedMembershipMethodIds: ["open-admission"],
selectedDecisionApproachIds: ["lazy-consensus"],
selectedConflictManagementIds: ["peer-mediation"],
selectedConflictManagementIds: ["conflict-ladder"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
const byName = new Map(rows.map((r) => [r.name, r.chips]));
expect(byName.get("Communication")).toEqual([
"Signal",
"In-Person Meetings",
"In-person meetings",
]);
expect(byName.get("Membership")).toEqual(["Open Access"]);
expect(byName.get("Decision-making")).toEqual(["Lazy Consensus"]);
expect(byName.get("Conflict management")).toEqual(["Peer Mediation"]);
expect(byName.get("Membership")).toEqual(["Open admission"]);
expect(byName.get("Decision-making")).toEqual(["Lazy consensus"]);
expect(byName.get("Conflict management")).toEqual(["Conflict ladder"]);
expect(byName.has("Values")).toBe(false);
});
@@ -116,7 +116,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Communication", chips: ["In-Person Meetings"] },
{ name: "Communication", chips: ["In-person meetings"] },
]);
});
+6 -3
View File
@@ -41,6 +41,7 @@ describe("buildPublishPayload", () => {
},
],
coreValues: [],
supermajorityShare: 75,
});
});
@@ -61,6 +62,7 @@ describe("buildPublishPayload", () => {
},
],
coreValues: [],
supermajorityShare: 75,
});
});
@@ -85,7 +87,7 @@ describe("buildPublishPayload", () => {
const r = buildPublishPayload({ title: "T", sections });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.document).toEqual({ sections, coreValues: [] });
expect(r.document).toEqual({ sections, coreValues: [], supermajorityShare: 75 });
});
it("filters invalid section entries from state.sections", () => {
@@ -101,6 +103,7 @@ describe("buildPublishPayload", () => {
expect(r.document).toEqual({
sections: [{ categoryName: "Values", entries: [{ title: "A", body: "B" }] }],
coreValues: [],
supermajorityShare: 75,
});
});
@@ -331,9 +334,9 @@ describe("buildPublishPayload — methodSelections", () => {
const r = buildPublishPayload({
title: "T",
selectedCommunicationMethodIds: ["signal"],
selectedMembershipMethodIds: ["open-access"],
selectedMembershipMethodIds: ["open-admission"],
selectedDecisionApproachIds: ["lazy-consensus"],
selectedConflictManagementIds: ["peer-mediation"],
selectedConflictManagementIds: ["conflict-ladder"],
});
expect(r.ok).toBe(true);
if (!r.ok) return;
@@ -4,9 +4,9 @@ import type { CommunityStructureChipSnapshotRow } from "../../app/(app)/create/t
import { buildCoreValueChipOptionsFromDraft } from "../../lib/create/coreValueChipOptionsFromDraft";
const PRESETS = [
{ label: "A", meaning: "am", signals: "as" },
{ label: "B", meaning: "bm", signals: "bs" },
{ label: "C", meaning: "cm", signals: "cs" },
{ id: "1", label: "A", meaning: "am", signals: "as" },
{ id: "2", label: "B", meaning: "bm", signals: "bs" },
{ id: "3", label: "C", meaning: "cm", signals: "cs" },
] as const;
describe("buildCoreValueChipOptionsFromDraft", () => {
+8 -9
View File
@@ -100,27 +100,26 @@ describe("GET /api/create-flow/methods", () => {
};
expect(json.section).toBe("communication");
expect(json.methods.length).toBe(communicationCatalog.length);
expect(json.methods[0].slug).toBe("loomio");
expect(json.methods[0].matches.score).toBe(2);
expect(json.methods[1].slug).toBe("in-person-meetings");
expect(json.methods[1].matches.score).toBe(1);
expect(json.methods[0].matches.score).toBeGreaterThan(json.methods[1].matches.score);
expect(findManyMock).not.toHaveBeenCalled();
expect(json.methods[0].label).toBeTruthy();
});
it("returns full catalog without matches when the DB query throws", async () => {
it("ranks from the deck files when the database is down", async () => {
findManyMock.mockRejectedValueOnce(new Error("db down"));
const res = await GET(
makeReq(
"https://x.test/api/create-flow/methods?section=communication&facet.size=oneMember",
"https://x.test/api/create-flow/methods?section=communication&facet.size=oneMember&facet.orgType=workersCoop",
),
undefined,
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
methods: Array<{ slug: string; matches?: unknown }>;
methods: Array<{ slug: string; matches?: { score: number } }>;
};
expect(json.methods.length).toBe(communicationCatalog.length);
expect(json.methods[0].matches).toBeUndefined();
expect(json.methods[0].matches?.score).toBeGreaterThan(0);
expect(findManyMock).not.toHaveBeenCalled();
});
it("returns all core values for section=coreValues", async () => {
@@ -137,7 +136,7 @@ describe("GET /api/create-flow/methods", () => {
};
expect(json.section).toBe("coreValues");
expect(json.methods.length).toBeGreaterThan(50);
expect(json.methods[0].id).toBe("1");
expect(json.methods[0].id).toBe("accessibility");
expect(json.methods[0].label).toBeTruthy();
expect(findManyMock).not.toHaveBeenCalled();
});
+40 -34
View File
@@ -23,39 +23,42 @@ const emptyEntry = (): DecisionApproachDetailEntry => ({
});
describe("decisionApproachKeyResources", () => {
it("reads the four key-resource items from messages", () => {
it("reads the decision checklist from messages", () => {
expect(decisionApproachKeyResourceItems()).toEqual([
{ id: "amend", label: "Amend your CommunityRule" },
{ id: "finances", label: "Steward finances" },
{ id: "project", label: "Project level decisions" },
{ id: "discipline", label: "Discipline and member termination" },
{ id: "everyday", label: "Everyday decisions" },
{ id: "big", label: "Big decisions" },
{ id: "amend", label: "Changing this Rule" },
{ id: "emergency", label: "Emergencies" },
{ id: "finances", label: "Shared money" },
{ id: "spending", label: "Spending" },
{ id: "roles", label: "Roles and working groups" },
]);
});
it("selects checked key-resource labels without rewriting applicableScope", () => {
const next = applyDecisionApproachKeyResources(emptyEntry(), [
"Steward finances",
"Shared money",
]);
expect(next.applicableScope).toEqual(["Daily Operations"]);
expect(next.selectedApplicableScope).toEqual(["Steward finances"]);
expect(next.selectedApplicableScope).toEqual(["Shared money"]);
});
it("keeps non-key chip selections when syncing sidebar checks", () => {
const next = applyDecisionApproachKeyResources(
{
...emptyEntry(),
applicableScope: ["Steward finances", "Daily Operations"],
applicableScope: ["Shared money", "Daily Operations"],
selectedApplicableScope: ["Daily Operations"],
},
["Steward finances"],
["Shared money"],
);
expect(next.applicableScope).toEqual([
"Steward finances",
"Shared money",
"Daily Operations",
]);
expect(next.selectedApplicableScope).toEqual([
"Daily Operations",
"Steward finances",
"Shared money",
]);
});
@@ -63,19 +66,19 @@ describe("decisionApproachKeyResources", () => {
expect(
decisionApproachScopeForPublish(
["Daily Operations", "Minor Expenditures"],
["Steward finances"],
["Shared money"],
),
).toEqual([
"Daily Operations",
"Minor Expenditures",
"Steward finances",
"Shared money",
]);
expect(
decisionApproachScopeForPublish(
["Daily Operations", "Minor Expenditures"],
["Daily Operations", "Steward finances"],
["Daily Operations", "Shared money"],
),
).toEqual(["Daily Operations", "Steward finances"]);
).toEqual(["Daily Operations", "Shared money"]);
expect(
decisionApproachScopeForPublish(
["Daily Operations", "Minor Expenditures"],
@@ -85,44 +88,44 @@ describe("decisionApproachKeyResources", () => {
});
it("maps checkbox ids to labels and back", () => {
expect(selectedKeyResourceLabelsFromCheckedIds(["amend", "project"])).toEqual(
["Amend your CommunityRule", "Project level decisions"],
expect(selectedKeyResourceLabelsFromCheckedIds(["amend", "big"])).toEqual(
["Big decisions", "Changing this Rule"],
);
expect(
decisionApproachKeyResourceIdsFromLabels([
"Project level decisions",
"Big decisions",
"Unknown",
"Amend your CommunityRule",
"Changing this Rule",
]),
).toEqual(["amend", "project"]);
).toEqual(["big", "amend"]);
});
it("derives sidebar checks from the open draft or selected approaches", () => {
const detailsById = {
"lazy-consensus": {
...emptyEntry(),
selectedApplicableScope: ["Steward finances"],
selectedApplicableScope: ["Shared money"],
},
"do-ocracy": {
...emptyEntry(),
selectedApplicableScope: ["Project level decisions"],
selectedApplicableScope: ["Big decisions"],
},
};
expect(
decisionApproachKeyResourceCheckboxIds({
detailsById,
selectedApproachIds: ["lazy-consensus", "do-ocracy"],
reminderIds: ["discipline"],
reminderIds: ["spending"],
}),
).toEqual(["finances", "project", "discipline"]);
).toEqual(["big", "finances", "spending"]);
expect(
decisionApproachKeyResourceCheckboxIds({
detailsById,
selectedApproachIds: ["lazy-consensus", "do-ocracy"],
reminderIds: ["discipline"],
reminderIds: ["spending"],
openDraft: {
...emptyEntry(),
selectedApplicableScope: ["Amend your CommunityRule"],
selectedApplicableScope: ["Changing this Rule"],
},
}),
).toEqual(["amend"]);
@@ -132,7 +135,7 @@ describe("decisionApproachKeyResources", () => {
const next = syncDecisionApproachKeyResourceDetails(
{ "lazy-consensus": emptyEntry() },
["do-ocracy"],
["Amend your CommunityRule"],
["Changing this Rule"],
(id) => ({
...emptyEntry(),
corePrinciple: id,
@@ -140,19 +143,19 @@ describe("decisionApproachKeyResources", () => {
}),
);
expect(next["lazy-consensus"]?.selectedApplicableScope).toEqual([
"Amend your CommunityRule",
"Changing this Rule",
]);
expect(next["do-ocracy"]?.corePrinciple).toBe("do-ocracy");
expect(next["do-ocracy"]?.applicableScope).toEqual(["Volunteer Tasks"]);
expect(next["do-ocracy"]?.selectedApplicableScope).toEqual([
"Amend your CommunityRule",
"Changing this Rule",
]);
});
it("compares string arrays by order", () => {
expect(stringArraysEqual(["a", "b"], ["a", "b"])).toBe(true);
expect(stringArraysEqual(["a", "b"], ["b", "a"])).toBe(false);
expect(decisionApproachKeyResourceLabels()).toHaveLength(4);
expect(decisionApproachKeyResourceLabels()).toHaveLength(7);
});
it("unions key-resource labels onto an existing scope list", () => {
@@ -160,10 +163,13 @@ describe("decisionApproachKeyResources", () => {
withDecisionApproachKeyResourceScopes(["Daily Operations"]),
).toEqual([
"Daily Operations",
"Amend your CommunityRule",
"Steward finances",
"Project level decisions",
"Discipline and member termination",
"Everyday decisions",
"Big decisions",
"Changing this Rule",
"Emergencies",
"Shared money",
"Spending",
"Roles and working groups",
]);
});
});
+46 -77
View File
@@ -1,93 +1,62 @@
import { describe, expect, it } from "vitest";
import { deriveCompactCards } from "../../app/(app)/create/hooks/useFacetRecommendations";
const methods = [
{ id: "alpha" },
{ id: "bravo" },
{ id: "charlie" },
{ id: "delta" },
{ id: "echo" },
{ id: "foxtrot" },
{ id: "golf" },
] as const;
describe("deriveCompactCards", () => {
it("falls back to authoring order with no badges when no facets selected", () => {
const result = deriveCompactCards(methods, {}, false, 5);
expect(result.compactCardIds).toEqual([
"alpha",
"bravo",
"charlie",
"delta",
"echo",
]);
it("falls back to deck order with no tags when no facets are chosen", () => {
const result = deriveCompactCards("decisionApproaches", {}, 5);
expect(result.compactCardIds).toHaveLength(5);
expect(result.recommendedIds.size).toBe(0);
expect(result.orderedIds[0]).toBe(result.compactCardIds[0]);
});
it("falls back to authoring order with no badges when facets selected but every score is zero", () => {
const result = deriveCompactCards(methods, {}, true, 5);
expect(result.compactCardIds).toEqual([
"alpha",
"bravo",
"charlie",
"delta",
"echo",
]);
expect(result.recommendedIds.size).toBe(0);
});
it("shows only recommended (matched) cards when fewer than the limit match", () => {
it("shows the top five in deck order and leaves every-rule cards untagged", () => {
const result = deriveCompactCards(
methods,
{ bravo: 2, delta: 1 },
true,
"decisionApproaches",
{
size: "thirteenToOneHundred",
mainKind: "workersCoop",
scale: ["local"],
stage: "established",
traits: { risky: true, sponsor: true, employer: true },
},
5,
);
// Caller is responsible for pre-ranking by score (rankMethodsByScore).
// This test passes already-ranked input; the hook just respects ordering
// and tags only the matched subset — no padding with unrecommended cards.
expect(result.compactCardIds).toEqual(["bravo", "delta"]);
expect([...result.recommendedIds].sort()).toEqual(["bravo", "delta"]);
});
it("caps recommended cards at the limit when more than `limit` match", () => {
const scores = {
alpha: 4,
bravo: 3,
charlie: 3,
delta: 2,
echo: 1,
foxtrot: 1,
golf: 1,
};
const result = deriveCompactCards(methods, scores, true, 5);
expect(result.compactCardIds).toEqual([
"alpha",
"bravo",
"charlie",
"delta",
"echo",
]);
expect(result.recommendedIds.size).toBe(5);
expect([...result.recommendedIds].sort()).toEqual([
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"decision-speeds",
"modified-consensus",
"emergency-decisions",
"on-site-lead",
"how-we-are-covered",
]);
expect(result.recommendedIds.has("decision-speeds")).toBe(false);
expect(result.recommendedIds.has("on-site-lead")).toBe(true);
});
it("returns a single card when only one method matches", () => {
const result = deriveCompactCards(methods, { charlie: 4 }, true, 5);
expect(result.compactCardIds).toEqual(["charlie"]);
expect([...result.recommendedIds]).toEqual(["charlie"]);
});
it("respects a smaller `limit` even when many methods match", () => {
const scores = { alpha: 4, bravo: 3, charlie: 3, delta: 2 };
const result = deriveCompactCards(methods, scores, true, 3);
expect(result.compactCardIds).toEqual(["alpha", "bravo", "charlie"]);
expect(result.recommendedIds.size).toBe(3);
it("shows seven when the screen asks for seven, with spending behind see all", () => {
const result = deriveCompactCards(
"decisionApproaches",
{
size: "thirteenToOneHundred",
mainKind: "workersCoop",
scale: ["local"],
stage: "established",
traits: { risky: true, sponsor: true, employer: true },
},
7,
);
expect(result.compactCardIds).toEqual([
"decision-speeds",
"modified-consensus",
"changing-this-rule",
"emergency-decisions",
"on-site-lead",
"how-we-are-covered",
"elected-board-of-directors",
]);
expect(result.orderedIds.slice(7, 10)).toEqual([
"shared-money",
"spending-limits",
"pay-and-hiring",
]);
});
});
+5 -5
View File
@@ -46,16 +46,16 @@ describe("governanceCatalog (CR-115)", () => {
});
}
it("core values: ids are 1-based positions with copy fields", () => {
it("core values: ids come from the value id field", () => {
const values = listCatalogCoreValues();
const messages = readJson<{
values: Array<string | { label: string }>;
values: Array<{ id: string; label: string }>;
}>("messages/en/create/customRule/coreValues.json");
expect(values.length).toBe(messages.values.length);
expect(values[0].id).toBe("1");
expect(values[0].label).toBeTruthy();
expect(values[0].id).toBe(messages.values[0].id);
expect(values[0].label).toBe(messages.values[0].label);
expect(typeof values[0].meaning).toBe("string");
expect(typeof values[0].signals).toBe("string");
expect(getCatalogCoreValue("1")).toEqual(values[0]);
expect(getCatalogCoreValue(values[0].id)).toEqual(values[0]);
});
});
+1 -1
View File
@@ -45,7 +45,7 @@ describe("governanceTemplateCatalog (Figma 21764-16435)", () => {
"consensus",
"do-ocracy",
"devolution",
"quadratic-governance",
"solidarity-network",
]);
expect(getGovernanceTemplatesForHome()).toHaveLength(4);
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { migrateLegacyCreateFlowState } from "../../lib/create/migrateLegacyCreateFlowState";
import { groupAnswersFromCreateFlowState } from "../../lib/create/groupAnswers";
describe("maturity and chip aliases", () => {
it("scores one maturity answer and keeps a draft that stored several", () => {
const draft = migrateLegacyCreateFlowState({
selectedMaturityIds: ["1", "2", "4"],
selectedOrganizationTypeIds: ["1"],
});
expect(draft.selectedMaturityIds).toEqual([
"earlyStage",
"growthStage",
"established",
]);
expect(draft.selectedOrganizationTypeIds).toEqual(["workersCoop"]);
const answers = groupAnswersFromCreateFlowState(draft);
expect(answers.stage).toBe("earlyStage");
expect(answers.mainKind).toBe("workersCoop");
});
it("loads Enterprise and Larger organization as Established", () => {
const fromId = migrateLegacyCreateFlowState({
selectedMaturityIds: ["4"],
communityStructureChipSnapshots: {
maturity: [
{ id: "1", label: "Early stage", state: "unselected" },
{ id: "2", label: "Growth stage", state: "unselected" },
{ id: "3", label: "Established", state: "unselected" },
{ id: "4", label: "Enterprise", state: "selected" },
],
},
});
expect(fromId.selectedMaturityIds).toEqual(["established"]);
expect(
fromId.communityStructureChipSnapshots?.maturity?.find(
(row) => row.id === "established",
)?.label,
).toBe("Established");
const fromLabel = migrateLegacyCreateFlowState({
selectedMaturityIds: ["larger-organization"],
communityStructureChipSnapshots: {
maturity: [
{ id: "larger-organization", label: "Larger organization", state: "selected" },
],
},
});
expect(fromLabel.selectedMaturityIds).toEqual(["established"]);
});
it("keeps the worker co-op pick when the label was Worker cooperative", () => {
const draft = migrateLegacyCreateFlowState({
selectedOrganizationTypeIds: ["1"],
communityStructureChipSnapshots: {
organizationTypes: [
{ id: "1", label: "Worker cooperative", state: "selected" },
],
},
});
expect(draft.selectedOrganizationTypeIds).toEqual(["workersCoop"]);
expect(
draft.communityStructureChipSnapshots?.organizationTypes?.find(
(row) => row.id === "workersCoop",
)?.label,
).toBe("Worker co-op");
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { migrateLegacyCreateFlowState } from "../../lib/create/migrateLegacyCreateFlowState";
import { resolveCoreValueId, resolveMethodId } from "../../lib/create/idAliases";
describe("deck id aliases", () => {
it("maps old card ids onto the remade decks", () => {
expect(resolveMethodId("membership", "open-access")).toEqual({
deck: "membership",
id: "open-admission",
});
expect(resolveMethodId("conflictManagement", "peer-mediation")).toEqual({
deck: "conflictManagement",
id: "conflict-ladder",
});
expect(resolveMethodId("decisionApproaches", "lazy-consensus")).toEqual({
deck: "decisionApproaches",
id: "lazy-consensus",
});
});
it("moves a cut card to the card that now holds its content", () => {
expect(resolveMethodId("decisionApproaches", "proof-of-work")).toEqual({
deck: "decisionApproaches",
id: "weighted-voting",
});
});
it("migrates a saved draft's cards and core values", () => {
const draft = migrateLegacyCreateFlowState({
selectedMembershipMethodIds: ["open-access"],
selectedConflictManagementIds: ["peer-mediation"],
selectedCoreValueIds: ["1", "5", "36"],
coreValuesChipsSnapshot: [
{ id: "1", label: "Accessibility", state: "selected" },
{ id: "5", label: "Altruism", state: "selected" },
{ id: "36", label: "Holocracy", state: "selected" },
],
});
expect(draft.selectedMembershipMethodIds).toEqual(["open-admission"]);
expect(draft.selectedConflictManagementIds).toEqual(["conflict-ladder"]);
expect(draft.selectedCoreValueIds).toEqual(["accessibility", "generosity"]);
expect(resolveCoreValueId("36")).toBeNull();
expect(draft.coreValuesChipsSnapshot?.map((row) => row.id)).toEqual([
"accessibility",
"generosity",
]);
expect(draft.coreValuesChipsSnapshot?.[1]?.label).toBe("Generosity");
});
it("is safe to run twice", () => {
const once = migrateLegacyCreateFlowState({
selectedMembershipMethodIds: ["open-access"],
selectedCoreValueIds: ["2"],
});
const twice = migrateLegacyCreateFlowState(once);
expect(twice.selectedMembershipMethodIds).toEqual(once.selectedMembershipMethodIds);
expect(twice.selectedCoreValueIds).toEqual(once.selectedCoreValueIds);
});
});
@@ -32,7 +32,7 @@ describe("methodCardFacetMatchesPresetForId", () => {
decisionApproachFacetMatchesPreset(
{
...p,
selectedApplicableScope: ["Steward finances"],
selectedApplicableScope: ["Shared money"],
},
uuid,
),
+11 -4
View File
@@ -66,13 +66,20 @@ describe("data/create/customRule/_facetGroups.json (CR-88)", () => {
const [filePath, jsonPointer] = block.source.split("#");
const file = readJson<Record<string, { label: string }[]>>(filePath);
const arrayKey = jsonPointer.replace(/^\//, "");
const arr = file[arrayKey];
const arr = file[arrayKey] as { id?: string }[];
expect(Array.isArray(arr), `${group}: ${block.source} → array`).toBe(true);
const positions = new Set(arr.map((_, i) => String(i + 1)));
const chipIds = new Set(
arr.map((row, i) =>
row && typeof row === "object" && "id" in row && typeof row.id === "string"
? row.id
: String(i + 1),
),
);
for (const [valueId, { chipId }] of Object.entries(block.values)) {
const retiredEnterprise = group === "maturity" && valueId === "enterprise";
expect(
positions.has(chipId),
`${group}.${valueId} chipId ${chipId} should be a position in ${block.source} (have ${[...positions].join(",")})`,
chipIds.has(chipId) || retiredEnterprise,
`${group}.${valueId} chipId ${chipId} should be an option id in ${block.source}`,
).toBe(true);
}
}
+24 -39
View File
@@ -23,7 +23,7 @@ beforeEach(() => {
findManyMock.mockReset();
});
describe("listMethodRecommendations (CR-88 §9.2)", () => {
describe("listMethodRecommendations", () => {
it("returns empty rankings when no facets are requested", async () => {
const result = await listMethodRecommendations({
section: "communication",
@@ -33,55 +33,40 @@ describe("listMethodRecommendations (CR-88 §9.2)", () => {
expect(findManyMock).not.toHaveBeenCalled();
});
it("scores methods by counting matched (group, value) pairs", async () => {
findManyMock.mockResolvedValueOnce([
{ slug: "loomio", group: "size", value: "thirteenToOneHundred" },
{ slug: "loomio", group: "orgType", value: "workersCoop" },
{ slug: "in-person", group: "size", value: "thirteenToOneHundred" },
]);
const result = await listMethodRecommendations({
section: "communication",
it("ranks from the job rule and scores maturity once", async () => {
const once = await listMethodRecommendations({
section: "decisionApproaches",
facets: {
size: ["thirteenToOneHundred"],
orgType: ["workersCoop"],
maturity: ["established"],
},
});
expect(result).toEqual({
rankedSlugs: ["loomio", "in-person"],
matchesBySlug: {
loomio: {
score: 2,
matchedFacets: ["size:thirteenToOneHundred", "orgType:workersCoop"],
},
"in-person": {
score: 1,
matchedFacets: ["size:thirteenToOneHundred"],
},
const several = await listMethodRecommendations({
section: "decisionApproaches",
facets: {
size: ["thirteenToOneHundred"],
orgType: ["workersCoop"],
maturity: ["established", "earlyStage", "enterprise"],
},
});
expect(once?.rankedSlugs).toEqual(several?.rankedSlugs);
expect(once?.rankedSlugs[0]).toBeTruthy();
expect(findManyMock).not.toHaveBeenCalled();
});
it("returns null on query failure (caller falls back to authoring order)", async () => {
findManyMock.mockRejectedValueOnce(new Error("db down"));
it("uses only the main kind for eligibility", async () => {
const result = await listMethodRecommendations({
section: "communication",
facets: { size: ["oneMember"] },
section: "decisionApproaches",
facets: {
size: ["thirteenToOneHundred"],
orgType: ["workersCoop", "forProfit"],
scale: ["local"],
maturity: ["established"],
},
});
expect(result).toBeNull();
});
it("dedupes (group, value) so the same row never double-counts", async () => {
findManyMock.mockResolvedValueOnce([
{ slug: "loomio", group: "size", value: "twoToFive" },
{ slug: "loomio", group: "size", value: "twoToFive" },
]);
const result = await listMethodRecommendations({
section: "communication",
facets: { size: ["twoToFive"] },
});
expect(result?.matchesBySlug["loomio"]?.score).toBe(1);
expect(result?.rankedSlugs).toContain("consent-sociocracy");
expect(findManyMock).not.toHaveBeenCalled();
});
});
@@ -276,7 +276,7 @@ describe("createFlowStateFromPublishedRule", () => {
expect(partial.editingPublishedRuleId).toBe("rule-1");
expect(partial.title).toBe("Oak");
expect(partial.communityContext).toBe("River cleanup");
expect(partial.selectedCoreValueIds).toEqual(["1"]);
expect(partial.selectedCoreValueIds).toEqual(["accessibility"]);
expect(partial.coreValuesChipsSnapshot?.[0]?.label).toBe("Ecology");
expect(partial.selectedCommunicationMethodIds).toEqual(["slack"]);
expect(partial.communicationMethodDetailsById?.slack?.corePrinciple).toBe(
@@ -32,7 +32,11 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
},
};
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
expect(out.map((s) => s.categoryName)).toEqual(["Values", "Communication"]);
expect(out.map((s) => s.categoryName)).toEqual([
"Values",
"Communication",
"Shared terms",
]);
expect(out[0].entries[0].title).toBe("Ecology");
expect(out[0].entries[0].body).toBe(
"We protect water.\n\nLitter = violation.",
@@ -50,7 +54,10 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
],
};
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
expect(out.map((s) => s.categoryName)).toEqual(["Membership"]);
expect(out.map((s) => s.categoryName)).toEqual([
"Membership",
"Shared terms",
]);
});
it("prefers document.coreValues over a parallel Values section in document.sections", () => {
@@ -66,8 +73,10 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
],
};
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
expect(out.length).toBe(1);
expect(out[0].categoryName).toBe("Values");
expect(out.map((section) => section.categoryName)).toEqual([
"Values",
"Shared terms",
]);
expect(out[0].entries[0].title).toBe("Should not duplicate");
expect(out[0].entries[0].body).toBe("x\n\ny");
});
@@ -80,7 +89,7 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
],
};
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
expect(out[0].entries[0].body).toMatch(/survival and success/);
expect(out[0].entries[0].body).toMatch(/well-being is bound up/);
});
it("replaces template placeholder bodies with preset copy, Values first", () => {
@@ -99,7 +108,11 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
],
};
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
expect(out.map((s) => s.categoryName)).toEqual(["Values", "Communication"]);
expect(out.map((s) => s.categoryName)).toEqual([
"Values",
"Communication",
"Shared terms",
]);
expect(out[0].entries[0].body).not.toContain("Suggested focus");
expect(out[0].entries[0].body.length).toBeGreaterThan(20);
expect(out[1].entries[0].blocks?.length).toBeGreaterThanOrEqual(1);
@@ -112,9 +125,12 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
{ categoryName: "X", entries: [{ title: "t", body: "b" }] },
],
};
expect(parsePublishedDocumentForCommunityRuleDisplay(doc)).toEqual(
doc.sections,
);
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
expect(out[0]).toEqual(doc.sections[0]);
expect(out.map((section) => section.categoryName)).toEqual([
"X",
"Shared terms",
]);
});
it("replaces stale document.sections method category with full methodSelections (custom rules)", () => {
+160
View File
@@ -0,0 +1,160 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { DECK_IDS } from "../../lib/create/deckTypes";
import type { DeckId, RecommendedCard } from "../../lib/create/deckTypes";
import { deckData, recommendDeck } from "../../lib/create/deckRecommend";
import { variantsOf } from "../../lib/create/reviewProfiles";
import type { ReviewProfile } from "../../lib/create/reviewProfiles";
import { methodSlugFromTitle } from "../../lib/create/methodSlugFromTitle";
import { resolveMethodId } from "../../lib/create/idAliases";
import useCasesCompletedRules from "../../messages/en/pages/useCasesCompletedRules.json";
const ROOT = path.resolve(__dirname, "..", "..");
function readJson<T>(rel: string): T {
return JSON.parse(readFileSync(path.join(ROOT, rel), "utf8")) as T;
}
const profiles = readJson<ReviewProfile[]>("data/create/profiles/real-group-profiles.json");
const founders = readJson<ReviewProfile[]>("data/create/profiles/one-member-founders.json");
function compact(card: RecommendedCard) {
const reason =
card.reason.kind === "added"
? `added:${card.reason.answer}`
: card.reason.kind === "opened"
? `opened:${card.reason.answer}`
: card.reason.answer
? `fit+${card.reason.answer}`
: "fit";
return {
id: card.id,
tag: card.tag,
tier: card.tier,
reason,
...(card.decidedBy ? { decidedBy: card.decidedBy } : {}),
...(card.alternative ? { alternative: card.alternative } : {}),
};
}
function picksFor(set: ReviewProfile[]) {
return set.flatMap(variantsOf).map((variant) => ({
profile: variant.profile,
variant: variant.variant,
decks: Object.fromEntries(
DECK_IDS.map((deck) => {
const result = recommendDeck(deckData(deck), variant.answers);
return [
deck,
{
shown: result.shown.map(compact),
waiting: result.waiting.map((card) => card.id),
},
];
}),
),
}));
}
function rowOrderSentence(set: ReviewProfile[]): string {
const base = set.flatMap(variantsOf).filter((variant) => variant.variant === "base");
const totals = { only: 0, score: 0, otherKinds: 0, rowOrder: 0 };
for (const deck of DECK_IDS) {
const jobs = deckData(deck).engine.jobs.filter((job) => job.displayTier !== "none");
for (const job of jobs) {
for (const variant of base) {
const winner = recommendDeck(deckData(deck), variant.answers).recommended.find(
(card) => card.job === job.id && card.tier !== "answers",
);
if (winner?.decidedBy) totals[winner.decidedBy] += 1;
}
}
}
const picks = totals.only + totals.score + totals.otherKinds + totals.rowOrder;
return `Of ${picks} picks for the 92 groups, row order settled ${totals.rowOrder}, other kinds ${totals.otherKinds} and fit ${totals.score}, and ${totals.only} had one eligible card.`;
}
const EXAMPLE_DECK_BY_CATEGORY: Record<string, DeckId> = {
Membership: "membership",
"Decision-Making": "decisionApproaches",
"Decision-making": "decisionApproaches",
"Conflict Management": "conflictManagement",
Communication: "communication",
};
function chosenCards(documentKey: string): { total: number; ids: string[] } {
const doc = (
useCasesCompletedRules as Record<
string,
{ document: { sections: { categoryName: string; entries: { title: string }[] }[] } }
>
)[documentKey];
const live = new Map(
DECK_IDS.map((deck) => [deck, new Set(Object.keys(deckData(deck).engine.cards))]),
);
let total = 0;
const ids: string[] = [];
for (const section of doc.document.sections) {
const deck = EXAMPLE_DECK_BY_CATEGORY[section.categoryName];
if (!deck) continue;
for (const entry of section.entries) {
total += 1;
const slug = methodSlugFromTitle(entry.title);
const resolved = resolveMethodId(deck, slug);
if (resolved && live.get(resolved.deck)?.has(resolved.id)) ids.push(resolved.id);
}
}
return { total, ids };
}
describe("real-group profiles", () => {
it("matches the reviewed 2 Nov package run", () => {
const expected = readJson<{ picks: unknown[] }>("tests/fixtures/profiles-picks.json");
expect(picksFor([...profiles, ...founders])).toEqual(expected.picks);
});
it("reports how often row order settled a pick", () => {
const summary = readFileSync(
path.join(ROOT, "tests/fixtures/profiles-summary.md"),
"utf8",
);
expect(summary).toContain(rowOrderSentence(profiles));
expect(profiles).toHaveLength(92);
expect(founders.length).toBeGreaterThan(0);
expect(JSON.stringify(profiles)).not.toMatch(/[A-Z][a-z]+ [A-Z][a-z]+/);
expect(JSON.stringify(founders)).not.toMatch(/[A-Z][a-z]+ [A-Z][a-z]+/);
});
it("counts how many example-rule cards the engine recommends", () => {
const base = profiles.flatMap(variantsOf).filter((variant) => variant.variant === "base");
const report: Record<string, { chosen: number; resolved: number; recommendedOnMost: number }> = {};
for (const key of ["mutualAidColorado", "foodNotBombs", "boulderCountyStreetMedics"] as const) {
const chosen = chosenCards(key);
let best = 0;
for (const variant of base) {
const recommended = new Set<string>();
for (const deck of DECK_IDS) {
for (const card of recommendDeck(deckData(deck), variant.answers).recommended) {
recommended.add(card.id);
}
}
let hit = 0;
for (const id of chosen.ids) if (recommended.has(id)) hit += 1;
if (hit > best) best = hit;
}
report[key] = {
chosen: chosen.total,
resolved: chosen.ids.length,
recommendedOnMost: best,
};
}
// The team's own Rule is not in the repo, so this locks the three example
// Rules. A change that moves a recommendation changes recommendedOnMost.
expect(report).toEqual({
mutualAidColorado: { chosen: 7, resolved: 6, recommendedOnMost: 4 },
foodNotBombs: { chosen: 8, resolved: 8, recommendedOnMost: 5 },
boulderCountyStreetMedics: { chosen: 9, resolved: 8, recommendedOnMost: 5 },
});
});
});
@@ -120,7 +120,7 @@ describe("sectionFromDecision", () => {
]);
const blocks = section?.entries[0]?.blocks ?? [];
expect(blocks).toContainEqual({
label: "Consensus Level",
label: "Consensus level",
body: "100%",
});
});