Implement create custom recommendations

This commit is contained in:
adilallo
2026-04-20 12:41:10 -06:00
parent e9dab04b34
commit 45bbbb8a35
75 changed files with 6403 additions and 1452 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
it("renders main heading", () => {
render(
<CreateFlowTextFieldScreen
messageNamespace="create.communityName"
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
/>,
@@ -22,7 +22,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
it("renders description and text field", () => {
render(
<CreateFlowTextFieldScreen
messageNamespace="create.communityName"
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
/>,
+8 -3
View File
@@ -106,17 +106,22 @@ describe("Create flow decision-approaches page", () => {
).toBeInTheDocument();
});
test("expanded view shows Label cards", async () => {
test("expanded view reveals additional non-recommended approaches", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
expect(
screen.queryByRole("button", { name: /^Sociocracy:/ }),
).not.toBeInTheDocument();
const toggle = screen.getByRole("button", {
name: "See all decision approaches",
});
await user.click(toggle);
const labelButtons = screen.getAllByRole("button", { name: /^Label/ });
expect(labelButtons.length).toBeGreaterThanOrEqual(1);
expect(
screen.getByRole("button", { name: /^Sociocracy:/ }),
).toBeInTheDocument();
});
test("clicking a card opens the create modal and confirming selects it", async () => {
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findManyMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
methodFacet: {
findMany: (...args: unknown[]) => findManyMock(...args),
},
},
}));
import { GET } from "../../app/api/create-flow/methods/route";
function makeReq(url: string) {
return { nextUrl: new URL(url) } as unknown as Parameters<typeof GET>[0];
}
beforeEach(() => {
findManyMock.mockReset();
});
describe("GET /api/create-flow/methods", () => {
it("400s on missing or unknown section", async () => {
const r1 = await GET(makeReq("https://x.test/api/create-flow/methods"));
expect(r1.status).toBe(400);
const r2 = await GET(
makeReq("https://x.test/api/create-flow/methods?section=foo"),
);
expect(r2.status).toBe(400);
});
it("returns ranked methods from the facet query", async () => {
findManyMock.mockResolvedValueOnce([
{ slug: "loomio", group: "size", value: "twoToFive" },
{ slug: "loomio", group: "orgType", value: "workersCoop" },
{ slug: "in-person", group: "size", value: "twoToFive" },
]);
const res = await GET(
makeReq(
"https://x.test/api/create-flow/methods?section=communication&facet.size=twoToFive&facet.orgType=workersCoop",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
section: string;
methods: { slug: string; matches: { score: number } }[];
};
expect(json.section).toBe("communication");
expect(json.methods.map((m) => m.slug)).toEqual(["loomio", "in-person"]);
expect(json.methods[0].matches.score).toBe(2);
});
it("returns empty methods when the DB query throws (caller falls back)", async () => {
findManyMock.mockRejectedValueOnce(new Error("db down"));
const res = await GET(
makeReq(
"https://x.test/api/create-flow/methods?section=communication&facet.size=oneMember",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as { methods: unknown[] };
expect(json.methods).toEqual([]);
});
});
+93
View File
@@ -0,0 +1,93 @@
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",
]);
expect(result.recommendedIds.size).toBe(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", () => {
const result = deriveCompactCards(
methods,
{ bravo: 2, delta: 1 },
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",
]);
});
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);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import path from "node:path";
import {
FACET_GROUP_IDS,
FACET_VALUE_IDS_BY_GROUP,
SECTION_IDS,
type SectionId,
facetGroupsFileSchema,
sectionFacetsSchema,
} from "../../lib/server/validation/methodFacetsSchemas";
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const SECTION_TO_MESSAGES_FILE: Record<SectionId, string> = {
communication: "messages/en/create/customRule/communication.json",
membership: "messages/en/create/customRule/membership.json",
decisionApproaches: "messages/en/create/customRule/decisionApproaches.json",
conflictManagement: "messages/en/create/customRule/conflictManagement.json",
};
function readJson<T>(rel: string): T {
return JSON.parse(readFileSync(path.join(REPO_ROOT, rel), "utf8")) as T;
}
describe("data/create/customRule parity (CR-88)", () => {
for (const section of SECTION_IDS) {
const messagesPath = SECTION_TO_MESSAGES_FILE[section];
const dataPath = `data/create/customRule/${section}.json`;
it(`${section}: facet file matches messages methods one-to-one`, () => {
const messages = readJson<{ methods: { id: string }[] }>(messagesPath);
const dataParsed = sectionFacetsSchema.safeParse(readJson(dataPath));
expect(dataParsed.success).toBe(true);
if (!dataParsed.success) return;
const messageSlugs = new Set(messages.methods.map((m) => m.id));
const dataSlugs = new Set(Object.keys(dataParsed.data));
const onlyInMessages = [...messageSlugs].filter((s) => !dataSlugs.has(s));
const onlyInData = [...dataSlugs].filter((s) => !messageSlugs.has(s));
expect(onlyInMessages, `${section} slugs missing from data/`).toEqual([]);
expect(onlyInData, `${section} slugs missing from messages/`).toEqual([]);
});
}
});
describe("data/create/customRule/_facetGroups.json (CR-88)", () => {
const groups = readJson<unknown>("data/create/customRule/_facetGroups.json");
it("matches the facetGroupsFileSchema", () => {
const parsed = facetGroupsFileSchema.safeParse(groups);
expect(parsed.success).toBe(true);
});
it("every chipId resolves to a real position in the referenced messages file", () => {
const parsed = facetGroupsFileSchema.parse(groups);
for (const group of FACET_GROUP_IDS) {
const block = parsed[group];
// source is "messages/en/.../foo.json#/<arrayKey>"; resolve relative to repo root.
const [filePath, jsonPointer] = block.source.split("#");
const file = readJson<Record<string, { label: string }[]>>(filePath);
const arrayKey = jsonPointer.replace(/^\//, "");
const arr = file[arrayKey];
expect(Array.isArray(arr), `${group}: ${block.source} → array`).toBe(true);
const positions = new Set(arr.map((_, i) => String(i + 1)));
for (const [valueId, { chipId }] of Object.entries(block.values)) {
expect(
positions.has(chipId),
`${group}.${valueId} chipId ${chipId} should be a position in ${block.source} (have ${[...positions].join(",")})`,
).toBe(true);
}
}
});
it("uses the canonical 19 value ids across the four groups", () => {
const parsed = facetGroupsFileSchema.parse(groups);
let total = 0;
for (const group of FACET_GROUP_IDS) {
const expected = new Set(FACET_VALUE_IDS_BY_GROUP[group]);
const actual = new Set(Object.keys(parsed[group].values));
expect(actual).toEqual(expected);
total += actual.size;
}
expect(total).toBe(19);
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
flattenRequestedFacets,
methodFacetsSchema,
parseRequestedFacetsFromSearchParams,
resolveFacetMatch,
} from "../../lib/server/validation/methodFacetsSchemas";
describe("methodFacetsSchema", () => {
it("accepts boolean cells and partial groups", () => {
expect(
methodFacetsSchema.safeParse({
size: { oneMember: true, twoToFive: false },
orgType: { dao: { match: true, weight: 0.5 } },
}).success,
).toBe(true);
});
it("rejects unknown facet group", () => {
expect(
methodFacetsSchema.safeParse({
nonsense: { foo: true },
}).success,
).toBe(false);
});
it("rejects unknown value within a known group", () => {
expect(
methodFacetsSchema.safeParse({
size: { gigantic: true },
}).success,
).toBe(false);
});
});
describe("resolveFacetMatch", () => {
it("treats undefined as { match:false }", () => {
expect(resolveFacetMatch(undefined)).toEqual({
match: false,
weight: null,
});
});
it("preserves weight when given as object", () => {
expect(resolveFacetMatch({ match: true, weight: 1.5 })).toEqual({
match: true,
weight: 1.5,
});
});
});
describe("parseRequestedFacetsFromSearchParams", () => {
it("collects facet.* params across multiple values per group", () => {
const params = new URLSearchParams();
params.append("facet.size", "oneMember");
params.append("facet.orgType", "dao");
params.append("facet.orgType", "nonprofit");
const out = parseRequestedFacetsFromSearchParams(params);
expect(out.size).toEqual(["oneMember"]);
expect(out.orgType?.sort()).toEqual(["dao", "nonprofit"]);
});
it("silently drops unknown groups and values", () => {
const params = new URLSearchParams();
params.append("facet.size", "tiny");
params.append("facet.unknown", "dao");
params.append("foo", "bar");
expect(parseRequestedFacetsFromSearchParams(params)).toEqual({});
});
});
describe("flattenRequestedFacets", () => {
it("emits one entry per (group, value) pair", () => {
const flat = flattenRequestedFacets({
size: ["oneMember", "twoToFive"],
orgType: ["dao"],
});
expect(flat).toEqual([
{ group: "size", value: "oneMember" },
{ group: "size", value: "twoToFive" },
{ group: "orgType", value: "dao" },
]);
});
});
+160
View File
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findManyMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
methodFacet: {
findMany: (...args: unknown[]) => findManyMock(...args),
},
},
}));
import {
listMethodRecommendations,
scoreTemplatesByFacets,
} from "../../lib/server/methodRecommendations";
beforeEach(() => {
findManyMock.mockReset();
});
describe("listMethodRecommendations (CR-88 §9.2)", () => {
it("returns empty rankings when no facets are requested", async () => {
const result = await listMethodRecommendations({
section: "communication",
facets: {},
});
expect(result).toEqual({ rankedSlugs: [], matchesBySlug: {} });
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",
facets: {
size: ["thirteenToOneHundred"],
orgType: ["workersCoop"],
},
});
expect(result).toEqual({
rankedSlugs: ["loomio", "in-person"],
matchesBySlug: {
loomio: {
score: 2,
matchedFacets: ["size:thirteenToOneHundred", "orgType:workersCoop"],
},
"in-person": {
score: 1,
matchedFacets: ["size:thirteenToOneHundred"],
},
},
});
});
it("returns null on query failure (caller falls back to authoring order)", async () => {
findManyMock.mockRejectedValueOnce(new Error("db down"));
const result = await listMethodRecommendations({
section: "communication",
facets: { size: ["oneMember"] },
});
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);
});
});
describe("scoreTemplatesByFacets (CR-88 §9.1)", () => {
it("aggregates per-method matches per template", async () => {
findManyMock.mockResolvedValueOnce([
{
section: "communication",
slug: "loomio",
group: "size",
value: "twoToFive",
},
{
section: "decisionApproaches",
slug: "consensus-decision-making",
group: "orgType",
value: "workersCoop",
},
]);
const result = await scoreTemplatesByFacets({
facets: {
size: ["twoToFive"],
orgType: ["workersCoop"],
},
templateMethods: [
{
templateSlug: "consensus",
methods: [
{ section: "communication", slug: "loomio" },
{
section: "decisionApproaches",
slug: "consensus-decision-making",
},
],
},
{
templateSlug: "monarch",
methods: [
{
section: "decisionApproaches",
slug: "benevolent-dictator",
},
],
},
],
});
expect(result).toEqual([
{
templateSlug: "consensus",
score: 2,
matchedFacets: [
"communication:loomio:size:twoToFive",
"decisionApproaches:consensus-decision-making:orgType:workersCoop",
],
},
{ templateSlug: "monarch", score: 0, matchedFacets: [] },
]);
});
it("returns 0-score entries for every template when facets are empty", async () => {
const result = await scoreTemplatesByFacets({
facets: {},
templateMethods: [
{ templateSlug: "consensus", methods: [] },
{ templateSlug: "monarch", methods: [] },
],
});
expect(result).toEqual([
{ templateSlug: "consensus", score: 0, matchedFacets: [] },
{ templateSlug: "monarch", score: 0, matchedFacets: [] },
]);
expect(findManyMock).not.toHaveBeenCalled();
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
methodSlugFromTitle,
templateMethodsFromBody,
} from "../../lib/server/templateMethods";
describe("methodSlugFromTitle", () => {
it.each([
["Consensus Decision-Making", "consensus-decision-making"],
["Consent (Sociocracy)", "consent-sociocracy"],
["Mutual aid", "mutual-aid"],
["Workers Cooperative", "workers-cooperative"],
[" Multiple spaces ", "multiple-spaces"],
])("%s -> %s", (input, expected) => {
expect(methodSlugFromTitle(input)).toBe(expected);
});
});
describe("templateMethodsFromBody", () => {
it("extracts (section, slug) pairs and skips Values", () => {
const body = {
sections: [
{
categoryName: "Values",
entries: [{ title: "Mutuality" }],
},
{
categoryName: "Communication",
entries: [
{ title: "In-Person Meetings" },
{ title: "Loomio" },
],
},
{
categoryName: "Decision-making",
entries: [{ title: "Consensus Decision-Making" }],
},
{
categoryName: "Conflict management",
entries: [{ title: "Restorative Justice" }],
},
{
categoryName: "Membership",
entries: [{ title: "Peer Sponsorship" }],
},
],
};
const result = templateMethodsFromBody(body);
expect(result).toEqual([
{ section: "communication", slug: "in-person-meetings" },
{ section: "communication", slug: "loomio" },
{ section: "decisionApproaches", slug: "consensus-decision-making" },
{ section: "conflictManagement", slug: "restorative-justice" },
{ section: "membership", slug: "peer-sponsorship" },
]);
});
it("dedupes within a template", () => {
const body = {
sections: [
{
categoryName: "Communication",
entries: [{ title: "Loomio" }, { title: "Loomio" }],
},
],
};
expect(templateMethodsFromBody(body)).toEqual([
{ section: "communication", slug: "loomio" },
]);
});
it("returns [] for malformed bodies", () => {
expect(templateMethodsFromBody(null)).toEqual([]);
expect(templateMethodsFromBody({})).toEqual([]);
expect(templateMethodsFromBody({ sections: "nope" })).toEqual([]);
});
});