Template recommendation implemented
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildFacetQueryString } from "../../lib/create/buildFacetQueryString";
|
||||
|
||||
describe("buildFacetQueryString", () => {
|
||||
it("maps community chip ids to facet.* query params", () => {
|
||||
const qs = buildFacetQueryString({
|
||||
selectedCommunitySizeIds: ["2"],
|
||||
selectedOrganizationTypeIds: ["3"],
|
||||
selectedScaleIds: ["1"],
|
||||
selectedMaturityIds: ["1"],
|
||||
});
|
||||
const params = new URLSearchParams(qs);
|
||||
expect(params.get("facet.size")).toBe("twoToFive");
|
||||
expect(params.get("facet.orgType")).toBe("openSource");
|
||||
expect(params.get("facet.scale")).toBe("local");
|
||||
expect(params.get("facet.maturity")).toBe("earlyStage");
|
||||
});
|
||||
|
||||
it("returns empty string when no selections", () => {
|
||||
expect(buildFacetQueryString({})).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
getStepIndex,
|
||||
parseReviewReturnSearchParam,
|
||||
resolveCreateFlowBackTarget,
|
||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||
} from "../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
describe("flowSteps", () => {
|
||||
@@ -106,6 +110,12 @@ describe("flowSteps", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("review Create from template uses fromFlow and recommendTemplates together", () => {
|
||||
expect(
|
||||
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
||||
).toBe("/templates?fromFlow=1&recommendTemplates=1");
|
||||
});
|
||||
|
||||
it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => {
|
||||
expect(
|
||||
parseReviewReturnSearchParam(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const templateFindManyMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
templateFacet: {
|
||||
findMany: (...args: unknown[]) => templateFindManyMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getTemplateFacetSlugSet,
|
||||
scoreTemplatesByTemplateFacets,
|
||||
} from "../../lib/server/methodRecommendations";
|
||||
|
||||
beforeEach(() => {
|
||||
templateFindManyMock.mockReset();
|
||||
});
|
||||
|
||||
describe("scoreTemplatesByTemplateFacets", () => {
|
||||
it("counts matches against TemplateFacet rows", async () => {
|
||||
templateFindManyMock.mockResolvedValueOnce([
|
||||
{ templateSlug: "consensus", group: "size", value: "oneMember" },
|
||||
{ templateSlug: "consensus", group: "orgType", value: "dao" },
|
||||
]);
|
||||
|
||||
const out = await scoreTemplatesByTemplateFacets({
|
||||
templateSlugs: ["consensus", "unknown-slug"],
|
||||
facets: {
|
||||
size: ["oneMember"],
|
||||
orgType: ["dao"],
|
||||
scale: [],
|
||||
maturity: [],
|
||||
},
|
||||
});
|
||||
|
||||
const consensus = out?.find((r) => r.templateSlug === "consensus");
|
||||
const unknown = out?.find((r) => r.templateSlug === "unknown-slug");
|
||||
expect(consensus?.score).toBe(2);
|
||||
expect(consensus?.matchedFacets).toEqual([
|
||||
"size:oneMember",
|
||||
"orgType:dao",
|
||||
]);
|
||||
expect(unknown?.score).toBe(0);
|
||||
});
|
||||
|
||||
it("returns zero when no facets requested", async () => {
|
||||
const out = await scoreTemplatesByTemplateFacets({
|
||||
templateSlugs: ["consensus"],
|
||||
facets: {},
|
||||
});
|
||||
expect(out?.[0]?.score).toBe(0);
|
||||
expect(templateFindManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTemplateFacetSlugSet", () => {
|
||||
it("returns distinct template slugs", async () => {
|
||||
templateFindManyMock.mockResolvedValueOnce([
|
||||
{ templateSlug: "consensus" },
|
||||
{ templateSlug: "do-ocracy" },
|
||||
]);
|
||||
|
||||
const set = await getTemplateFacetSlugSet();
|
||||
expect(set?.has("consensus")).toBe(true);
|
||||
expect(set?.has("do-ocracy")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
deriveRecommendedTemplateSlugs,
|
||||
gridEntriesWithFacetScores,
|
||||
} from "../../lib/templates/templateGridPresentation";
|
||||
import { fetchRankedTemplatesByFacets } from "../../lib/create/fetchTemplates";
|
||||
import type { RuleTemplateDto } from "../../lib/create/fetchTemplates";
|
||||
|
||||
const minimalTemplate = (slug: string, title: string): RuleTemplateDto => ({
|
||||
id: "x",
|
||||
slug,
|
||||
title,
|
||||
category: null,
|
||||
description: null,
|
||||
body: null,
|
||||
sortOrder: 0,
|
||||
featured: false,
|
||||
});
|
||||
|
||||
describe("deriveRecommendedTemplateSlugs", () => {
|
||||
it("returns at most limit slugs in the top score tier (API order for ties)", () => {
|
||||
const templates = ["a", "b", "c", "d", "e", "f"].map((s) => ({ slug: s }));
|
||||
const scores = Object.fromEntries(
|
||||
["a", "b", "c", "d", "e", "f"].map((s) => [
|
||||
s,
|
||||
{ score: 1, matchedFacets: [] as string[] },
|
||||
]),
|
||||
);
|
||||
const set = deriveRecommendedTemplateSlugs(templates, scores, 5);
|
||||
expect(set.size).toBe(5);
|
||||
expect([...set]).toEqual(["a", "b", "c", "d", "e"]);
|
||||
});
|
||||
|
||||
it("only recommends the highest score group, not lower tiers to fill the cap", () => {
|
||||
const templates = ["a", "b", "c", "d", "e"].map((s) => ({ slug: s }));
|
||||
const scores = {
|
||||
a: { score: 4, matchedFacets: [] as string[] },
|
||||
b: { score: 4, matchedFacets: [] as string[] },
|
||||
c: { score: 3, matchedFacets: [] as string[] },
|
||||
d: { score: 3, matchedFacets: [] as string[] },
|
||||
e: { score: 3, matchedFacets: [] as string[] },
|
||||
};
|
||||
const set = deriveRecommendedTemplateSlugs(templates, scores, 5);
|
||||
expect([...set]).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gridEntriesWithFacetScores", () => {
|
||||
it("sets recommended true only for top compact matches (like card decks)", () => {
|
||||
const t = minimalTemplate("do-ocracy", "Do-ocracy");
|
||||
const [row] = gridEntriesWithFacetScores([t], {
|
||||
"do-ocracy": { score: 3, matchedFacets: ["a"] },
|
||||
});
|
||||
expect(row.recommended).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mark lower-scoring templates recommended when a higher tier exists", () => {
|
||||
const high = [
|
||||
minimalTemplate("a", "A"),
|
||||
minimalTemplate("b", "B"),
|
||||
];
|
||||
const low = minimalTemplate("c", "C");
|
||||
const rows = gridEntriesWithFacetScores([...high, low], {
|
||||
a: { score: 4, matchedFacets: [] },
|
||||
b: { score: 4, matchedFacets: [] },
|
||||
c: { score: 3, matchedFacets: [] },
|
||||
});
|
||||
const rec = rows.filter((r) => r.recommended).map((r) => r.slug);
|
||||
expect(rec).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("caps top-tier recommended badges to compactRecommendedLimit", () => {
|
||||
const slugs = ["a", "b", "c", "d", "e", "f"];
|
||||
const templates = slugs.map((s) => minimalTemplate(s, s));
|
||||
const scores = Object.fromEntries(
|
||||
slugs.map((s) => [s, { score: 1, matchedFacets: [] as string[] }]),
|
||||
);
|
||||
const rows = gridEntriesWithFacetScores(templates, scores, {
|
||||
compactRecommendedLimit: 5,
|
||||
});
|
||||
expect(rows.filter((r) => r.recommended).map((r) => r.slug)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets recommended false when score is zero or missing", () => {
|
||||
const t = minimalTemplate("consensus", "Consensus");
|
||||
const [a] = gridEntriesWithFacetScores([t], {
|
||||
consensus: { score: 0, matchedFacets: [] },
|
||||
});
|
||||
const [b] = gridEntriesWithFacetScores([t], {});
|
||||
expect(a.recommended).toBe(false);
|
||||
expect(b.recommended).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRankedTemplatesByFacets", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("parses ok JSON with templates and scores", async () => {
|
||||
const template = minimalTemplate("s", "T");
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
templates: [template],
|
||||
scores: { s: { score: 1, matchedFacets: ["size:oneMember"] } },
|
||||
}),
|
||||
} as Response);
|
||||
const r = await fetchRankedTemplatesByFacets({
|
||||
facetQuery: "facet.size=oneMember",
|
||||
});
|
||||
expect("error" in r).toBe(false);
|
||||
if (!("error" in r)) {
|
||||
expect(r.templates).toEqual([template]);
|
||||
expect(r.scores.s?.score).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error when facetQuery is empty", async () => {
|
||||
const r = await fetchRankedTemplatesByFacets({ facetQuery: "" });
|
||||
expect("error" in r).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user