// @vitest-environment node // Pure logic (grouping/cap/rank) — no DOM. jsdom is not installed in this // workspace, so the web default (jsdom) is unreachable; node-env, bannered. import { describe, expect, it } from "vitest"; import { parseMentions } from "@factory/shared/mentions"; import { buildMentionGroups, mentionActive, mentionToken, MENTION_GROUP_CAP, type MentionSource } from "@/components/mentions/mention-model"; const source: MentionSource = { people: [ { id: "u_ana", name: "Ana", email: "a@x", image: null }, { id: "u_bob", name: "Bob", email: "b@x", image: null }, { id: "u_carol", name: "Carol", email: "c@x", image: null }, { id: "u_me", name: "Me", email: "me@x", image: null } ], teams: [ { id: "t_eng", name: "Engineering", memberCount: 12 }, { id: "t_design", name: "Design", memberCount: 4 } ], pages: [ { id: "p_launch", title: "Launch Plan" }, { id: "p_q3", title: "Q3 Roadmap" }, { id: "p_meeting", title: "Meeting Notes" } ], databases: [ { id: "d_tasks", title: "Tasks" }, { id: "d_bugs", title: "Bugs" } ] }; describe("mentionToken", () => { it("produces the canonical @: token", () => { expect(mentionToken("user", "u_ana")).toBe("@user:u_ana"); expect(mentionToken("team", "t_eng")).toBe("@team:t_eng"); expect(mentionToken("page", "p_q3")).toBe("@page:p_q3"); expect(mentionToken("database", "d_tasks")).toBe("@database:d_tasks"); }); }); describe("mentionActive (the @ trigger)", () => { it("detects @ at the start with the typed query", () => { const a = mentionActive("Bob @eng"); expect(a).toEqual({ start: 4, end: 8, query: "eng" }); }); it("fires on a bare @", () => { expect(mentionActive("hi @")).toEqual({ start: 3, end: 4, query: "" }); }); it("does NOT fire on an email-local @ (no preceding space/start)", () => { expect(mentionActive("bob@x.com")).toBeNull(); }); it("does NOT fire when the @ token is already committed (a space after)", () => { expect(mentionActive("@user:u_ana Bob")).toBeNull(); }); it("does NOT fire on @ followed by a non-token char (e.g. a space)", () => { expect(mentionActive("hi @ ")).toBeNull(); }); }); describe("buildMentionGroups", () => { it("returns the fixed group order: People → Teams → Pages → Databases", () => { const g = buildMentionGroups(source); expect(g.map((x) => x.key)).toEqual(["people", "teams", "pages", "databases"]); expect(g.map((x) => x.kind)).toEqual(["user", "team", "page", "database"]); }); it("excludes the current user from People (no self-mention)", () => { const g = buildMentionGroups(source, { currentUserId: "u_me" }); const people = g.find((x) => x.key === "people")!; expect(people.items.map((i) => i.id)).toEqual(["u_ana", "u_bob", "u_carol"]); }); it("drops items whose token is already in the text (no duplicates)", () => { const g = buildMentionGroups(source, { existingText: "hey @user:u_ana ping" }); const people = g.find((x) => x.key === "people")!; expect(people.items.map((i) => i.id)).not.toContain("u_ana"); // the other people + all other groups still present expect(g.find((x) => x.key === "teams")!.items).toHaveLength(2); }); it("empty query → everything, in source order", () => { const g = buildMentionGroups(source); expect(g.find((x) => x.key === "pages")!.items.map((i) => i.label)).toEqual([ "Launch Plan", "Q3 Roadmap", "Meeting Notes" ]); }); it("a query keeps only fuzzy matches, ranked best-first (prefix wins)", () => { const g = buildMentionGroups(source, { query: "lan" }); const pages = g.find((x) => x.key === "pages")!; // "Launch Plan" is the only page containing "lan" as a subsequence expect(pages.items.map((i) => i.label)).toEqual(["Launch Plan"]); }); it("a query that matches nothing yields an empty group (filtered out)", () => { const g = buildMentionGroups(source, { query: "zzzz" }); // every group is empty → the whole list is empty expect(g).toEqual([]); }); it("caps each group at MENTION_GROUP_CAP", () => { const big: MentionSource = { ...source, people: Array.from({ length: 40 }, (_, i) => ({ id: `u_${i}`, name: `User ${i}`, email: `${i}@x`, image: null })) }; const g = buildMentionGroups(big); expect(g.find((x) => x.key === "people")!.items).toHaveLength(MENTION_GROUP_CAP); }); it("each item carries its canonical token", () => { const g = buildMentionGroups(source); const team = g.find((x) => x.key === "teams")!.items[0]!; const database = g.find((x) => x.key === "databases")!.items[0]!; expect(team.token).toBe("@team:t_eng"); expect(database.token).toBe("@database:d_tasks"); }); it("teams carry a member-count sublabel", () => { const g = buildMentionGroups(source); expect(g.find((x) => x.key === "teams")!.items[0]!.sublabel).toBe("12 members"); }); }); /** * CONTRACT (the load-bearing part of R3): the token the popup inserts must be * exactly what the frozen shared `parseMentions` (the server's mention parser) * turns into the stored `mentionIds`/`pageMentionIds`. If the token format * drifts from `parseMentions`, the picker "works" but nothing is ever * notified — this test is the guard. Uses cuid-like ids (≥ 8 chars — the * parser's `[A-Za-z0-9_-]{8,}` minimum; short test ids would not parse). */ describe("token ↔ parseMentions contract", () => { it("a picked user token round-trips into parseMentions().users", () => { const g = buildMentionGroups({ people: [{ id: "uana000000001", name: "Ana", image: null }], teams: [], pages: [], databases: [] }); const token = g.find((x) => x.key === "people")!.items[0]!.token; expect(parseMentions(`hi ${token} thanks`).users).toEqual(["uana000000001"]); }); it("a team token round-trips into parseMentions().teams", () => { expect(parseMentions("ping @team:eng00000001").teams).toEqual(["eng00000001"]); }); it("page + database tokens round-trip into pages/databases arrays", () => { const parsed = parseMentions("see @page:page0000001 and @database:db0000000001"); expect(parsed.pages).toEqual(["page0000001"]); expect(parsed.databases).toEqual(["db0000000001"]); }); it("mentionToken emits the @kind:id form the parser expects", () => { expect(mentionToken("user", "abc123456")).toBe("@user:abc123456"); // and a sub-8-char id is NOT a valid token per the parser (guard): expect(parseMentions("@user:short").users).toEqual([]); }); });