1
2
3
4import { describe, expect, it } from "vitest";
5import {
6 RECENT_CAP,
7 addRecent,
8 deepLinkFor,
9 groupResultsByType,
10 isRecordableQuery,
11 paletteSearchResult,
12 recentItemHref,
13 savedSearchLabel
14} from "../search";
15import type { SearchResult, SearchType } from "../types";
16
17
18function item(partial: Partial<SearchResult> & { type: SearchType; id: string }): SearchResult {
19 return {
20 docId: `${partial.type}:${partial.id}`,
21 title: `Title ${partial.id}`,
22 updatedAt: "2025-01-01T00:00:00.000Z",
23 workspaceId: "ws-1",
24 score: 1,
25 source: "opensearch",
26 ...partial
27 } as SearchResult;
28}
29
30describe("groupResultsByType", () => {
31 it("groups by the 7 known types in the fixed display order", () => {
32 const groups = groupResultsByType([
33 item({ type: "file", id: "f1" }),
34 item({ type: "page", id: "p1" }),
35 item({ type: "record", id: "r1" }),
36 item({ type: "page", id: "p2" }),
37 item({ type: "user", id: "u1" }),
38 item({ type: "comment", id: "c1" }),
39 item({ type: "block", id: "b1" }),
40 item({ type: "database", id: "d1" })
41 ]);
42 expect(groups.map((g) => g.type)).toEqual([
43 "page",
44 "database",
45 "record",
46 "block",
47 "comment",
48 "file",
49 "user"
50 ]);
51 });
52
53 it("keeps intra-group relevance order and counts", () => {
54 const groups = groupResultsByType([
55 item({ type: "page", id: "p1" }),
56 item({ type: "record", id: "r1" }),
57 item({ type: "page", id: "p2" }),
58 item({ type: "record", id: "r2" }),
59 item({ type: "page", id: "p3" })
60 ]);
61 expect(groups).toHaveLength(2);
62 const [pages, records] = groups;
63 expect(pages?.items.map((i) => i.id)).toEqual(["p1", "p2", "p3"]);
64 expect(records?.items).toHaveLength(2);
65 });
66
67 it("drops items of unknown types and returns [] for empty input", () => {
68 expect(groupResultsByType([])).toEqual([]);
69 const unknownType = "nope" as unknown as SearchType;
70 const groups = groupResultsByType([
71 item({ type: "page", id: "p1" }),
72 item({ type: unknownType, id: "x1" })
73 ]);
74 expect(groups).toHaveLength(1);
75 expect(groups[0]?.items).toHaveLength(1);
76 });
77});
78
79describe("deepLinkFor", () => {
80 it("page → /w/{slug}/page/{id}", () => {
81 expect(deepLinkFor(item({ type: "page", id: "p1" }), "acme")).toBe("/w/acme/page/p1");
82 });
83
84 it("database → /w/{slug}/databases/{id}; record → containing database (databaseId ?? id)", () => {
85 expect(deepLinkFor(item({ type: "database", id: "d1" }), "acme")).toBe("/w/acme/databases/d1");
86 expect(deepLinkFor(item({ type: "record", id: "r1", databaseId: "d9" }), "acme")).toBe("/w/acme/databases/d9");
87 expect(deepLinkFor(item({ type: "record", id: "r2" }), "acme")).toBe("/w/acme/databases/r2");
88 });
89
90 it("block → parent page when present, null otherwise; null slug → null", () => {
91 expect(deepLinkFor(item({ type: "block", id: "b1", pageId: "p7" }), "acme")).toBe("/w/acme/page/p7");
92 expect(deepLinkFor(item({ type: "block", id: "b2" }), "acme")).toBeNull();
93 expect(deepLinkFor(item({ type: "page", id: "p1" }), null)).toBeNull();
94 });
95
96 it("comment/file/user → item url, else containing page, else null", () => {
97 expect(deepLinkFor(item({ type: "comment", id: "c1", url: "/w/acme/page/p3#comment-c1" }), "acme")).toBe(
98 "/w/acme/page/p3#comment-c1"
99 );
100 expect(deepLinkFor(item({ type: "file", id: "f1", pageId: "p5" }), "acme")).toBe("/w/acme/page/p5");
101 expect(deepLinkFor(item({ type: "user", id: "u1" }), "acme")).toBeNull();
102 });
103});
104
105describe("addRecent (recent-list semantics — mirrors PUT /search/recent)", () => {
106 it("prepends, dedupes, newest first", () => {
107 expect(addRecent([], "alpha")).toEqual(["alpha"]);
108 expect(addRecent(["alpha"], "beta")).toEqual(["beta", "alpha"]);
109 expect(addRecent(["beta", "alpha"], "alpha")).toEqual(["alpha", "beta"]);
110 });
111
112 it("trims and ignores blank queries", () => {
113 expect(addRecent(["x"], " ")).toEqual(["x"]);
114 expect(addRecent(["x"], null)).toEqual(["x"]);
115 expect(addRecent([], " gamma ")).toEqual(["gamma"]);
116 });
117
118 it("caps at RECENT_CAP (20) keeping the newest, dropping the oldest", () => {
119 const list = Array.from({ length: RECENT_CAP }, (_, i) => `q${i}`);
120 const next = addRecent(list, "newest");
121 expect(next).toHaveLength(RECENT_CAP);
122 expect(next[0]).toBe("newest");
123 expect(next).not.toContain(`q${RECENT_CAP - 1}`);
124 expect(next).toContain("q0");
125 });
126});
127
128describe("isRecordableQuery / paletteSearchResult", () => {
129 it("only records non-blank queries", () => {
130 expect(isRecordableQuery("x")).toBe(true);
131 expect(isRecordableQuery(" ")).toBe(false);
132 expect(isRecordableQuery("")).toBe(false);
133 });
134
135 it("keeps a database hit's id (the /databases/:id route takes the database id) and adds a path hint", () => {
136 const mapped = paletteSearchResult(item({ type: "database", id: "d1", pageId: "p1" }));
137 expect(mapped.id).toBe("d1");
138 expect(mapped.path).toEqual(["Database"]);
139 });
140
141 it("maps a record hit to its containing database (the palette's navigate target)", () => {
142 const mapped = paletteSearchResult(item({ type: "record", id: "r1", databaseId: "d9" }));
143 expect(mapped.id).toBe("d9");
144 expect(mapped.path).toEqual(["Database record"]);
145 });
146
147 it("maps a block hit to its containing page (the palette's navigate target)", () => {
148 const mapped = paletteSearchResult(item({ type: "block", id: "b1", pageId: "p7" }));
149 expect(mapped.id).toBe("p7");
150 expect(mapped.path).toEqual(["Block"]);
151 });
152
153 it("keeps a plain page hit's id and adds no path", () => {
154 const mapped = paletteSearchResult(item({ type: "page", id: "p1" }));
155 expect(mapped.id).toBe("p1");
156 expect(mapped.path).toBeUndefined();
157 });
158});
159
160describe("recentItemHref (T-153 recently-viewed)", () => {
161 it("routes databases to /databases/:id and every other kind to /page/:id", () => {
162 expect(recentItemHref({ itemType: "database", refId: "d1", at: "" }, "w")).toBe("/w/w/databases/d1");
163 expect(recentItemHref({ itemType: "page", refId: "p1", at: "" }, "w")).toBe("/w/w/page/p1");
164 expect(recentItemHref({ itemType: "whiteboard", refId: "p2", at: "" }, "w")).toBe("/w/w/page/p2");
165 expect(recentItemHref({ itemType: "task", refId: "p3", at: "" }, "w")).toBe("/w/w/page/p3");
166 });
167
168 it("URL-encodes the workspace slug and the id", () => {
169 expect(recentItemHref({ itemType: "page", refId: "a b", at: "" }, "my ws")).toBe("/w/my%20ws/page/a%20b");
170 });
171
172 it("returns null when there is no workspace to scope to", () => {
173 expect(recentItemHref({ itemType: "page", refId: "p1", at: "" }, "")).toBeNull();
174 });
175});
176
177describe("savedSearchLabel (T-154 saved searches)", () => {
178 it("uses the query, truncating long ones", () => {
179 expect(savedSearchLabel({ q: "pricing" })).toBe("pricing");
180 expect(savedSearchLabel({ q: " ".repeat(50) })).toBe("Saved search");
181 const long = "x".repeat(60);
182 const label = savedSearchLabel({ q: long });
183 expect(label.length).toBe(40);
184 expect(label.endsWith("…")).toBe(true);
185 });
186
187 it("falls back for a null/empty query", () => {
188 expect(savedSearchLabel(null)).toBe("Saved search");
189 expect(savedSearchLabel({ q: "" })).toBe("Saved search");
190 expect(savedSearchLabel(undefined, "Custom")).toBe("Custom");
191 });
192});
193