Implement create custom recommendations
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user