1
2
3
4import { describe, expect, it } from "vitest";
5import { parseMentions } from "@factory/shared/mentions";
6import {
7 buildMentionGroups,
8 mentionActive,
9 mentionToken,
10 MENTION_GROUP_CAP,
11 type MentionSource
12} from "@/components/mentions/mention-model";
13
14const source: MentionSource = {
15 people: [
16 { id: "u_ana", name: "Ana", email: "a@x", image: null },
17 { id: "u_bob", name: "Bob", email: "b@x", image: null },
18 { id: "u_carol", name: "Carol", email: "c@x", image: null },
19 { id: "u_me", name: "Me", email: "me@x", image: null }
20 ],
21 teams: [
22 { id: "t_eng", name: "Engineering", memberCount: 12 },
23 { id: "t_design", name: "Design", memberCount: 4 }
24 ],
25 pages: [
26 { id: "p_launch", title: "Launch Plan" },
27 { id: "p_q3", title: "Q3 Roadmap" },
28 { id: "p_meeting", title: "Meeting Notes" }
29 ],
30 databases: [
31 { id: "d_tasks", title: "Tasks" },
32 { id: "d_bugs", title: "Bugs" }
33 ]
34};
35
36describe("mentionToken", () => {
37 it("produces the canonical @<kind>:<id> token", () => {
38 expect(mentionToken("user", "u_ana")).toBe("@user:u_ana");
39 expect(mentionToken("team", "t_eng")).toBe("@team:t_eng");
40 expect(mentionToken("page", "p_q3")).toBe("@page:p_q3");
41 expect(mentionToken("database", "d_tasks")).toBe("@database:d_tasks");
42 });
43});
44
45describe("mentionActive (the @ trigger)", () => {
46 it("detects @ at the start with the typed query", () => {
47 const a = mentionActive("Bob @eng");
48 expect(a).toEqual({ start: 4, end: 8, query: "eng" });
49 });
50 it("fires on a bare @", () => {
51 expect(mentionActive("hi @")).toEqual({ start: 3, end: 4, query: "" });
52 });
53 it("does NOT fire on an email-local @ (no preceding space/start)", () => {
54 expect(mentionActive("bob@x.com")).toBeNull();
55 });
56 it("does NOT fire when the @ token is already committed (a space after)", () => {
57 expect(mentionActive("@user:u_ana Bob")).toBeNull();
58 });
59 it("does NOT fire on @ followed by a non-token char (e.g. a space)", () => {
60 expect(mentionActive("hi @ ")).toBeNull();
61 });
62});
63
64describe("buildMentionGroups", () => {
65 it("returns the fixed group order: People → Teams → Pages → Databases", () => {
66 const g = buildMentionGroups(source);
67 expect(g.map((x) => x.key)).toEqual(["people", "teams", "pages", "databases"]);
68 expect(g.map((x) => x.kind)).toEqual(["user", "team", "page", "database"]);
69 });
70
71 it("excludes the current user from People (no self-mention)", () => {
72 const g = buildMentionGroups(source, { currentUserId: "u_me" });
73 const people = g.find((x) => x.key === "people")!;
74 expect(people.items.map((i) => i.id)).toEqual(["u_ana", "u_bob", "u_carol"]);
75 });
76
77 it("drops items whose token is already in the text (no duplicates)", () => {
78 const g = buildMentionGroups(source, { existingText: "hey @user:u_ana ping" });
79 const people = g.find((x) => x.key === "people")!;
80 expect(people.items.map((i) => i.id)).not.toContain("u_ana");
81
82 expect(g.find((x) => x.key === "teams")!.items).toHaveLength(2);
83 });
84
85 it("empty query → everything, in source order", () => {
86 const g = buildMentionGroups(source);
87 expect(g.find((x) => x.key === "pages")!.items.map((i) => i.label)).toEqual([
88 "Launch Plan",
89 "Q3 Roadmap",
90 "Meeting Notes"
91 ]);
92 });
93
94 it("a query keeps only fuzzy matches, ranked best-first (prefix wins)", () => {
95 const g = buildMentionGroups(source, { query: "lan" });
96 const pages = g.find((x) => x.key === "pages")!;
97
98 expect(pages.items.map((i) => i.label)).toEqual(["Launch Plan"]);
99 });
100
101 it("a query that matches nothing yields an empty group (filtered out)", () => {
102 const g = buildMentionGroups(source, { query: "zzzz" });
103
104 expect(g).toEqual([]);
105 });
106
107 it("caps each group at MENTION_GROUP_CAP", () => {
108 const big: MentionSource = {
109 ...source,
110 people: Array.from({ length: 40 }, (_, i) => ({
111 id: `u_${i}`,
112 name: `User ${i}`,
113 email: `${i}@x`,
114 image: null
115 }))
116 };
117 const g = buildMentionGroups(big);
118 expect(g.find((x) => x.key === "people")!.items).toHaveLength(MENTION_GROUP_CAP);
119 });
120
121 it("each item carries its canonical token", () => {
122 const g = buildMentionGroups(source);
123 const team = g.find((x) => x.key === "teams")!.items[0]!;
124 const database = g.find((x) => x.key === "databases")!.items[0]!;
125 expect(team.token).toBe("@team:t_eng");
126 expect(database.token).toBe("@database:d_tasks");
127 });
128
129 it("teams carry a member-count sublabel", () => {
130 const g = buildMentionGroups(source);
131 expect(g.find((x) => x.key === "teams")!.items[0]!.sublabel).toBe("12 members");
132 });
133});
134
135
143describe("token ↔ parseMentions contract", () => {
144 it("a picked user token round-trips into parseMentions().users", () => {
145 const g = buildMentionGroups({
146 people: [{ id: "uana000000001", name: "Ana", image: null }],
147 teams: [],
148 pages: [],
149 databases: []
150 });
151 const token = g.find((x) => x.key === "people")!.items[0]!.token;
152 expect(parseMentions(`hi ${token} thanks`).users).toEqual(["uana000000001"]);
153 });
154
155 it("a team token round-trips into parseMentions().teams", () => {
156 expect(parseMentions("ping @team:eng00000001").teams).toEqual(["eng00000001"]);
157 });
158
159 it("page + database tokens round-trip into pages/databases arrays", () => {
160 const parsed = parseMentions("see @page:page0000001 and @database:db0000000001");
161 expect(parsed.pages).toEqual(["page0000001"]);
162 expect(parsed.databases).toEqual(["db0000000001"]);
163 });
164
165 it("mentionToken emits the @kind:id form the parser expects", () => {
166 expect(mentionToken("user", "abc123456")).toBe("@user:abc123456");
167
168 expect(parseMentions("@user:short").users).toEqual([]);
169 });
170});
171