Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

issue-similarity.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

issue-similarity.test.tsBlame296 lines · 1 contributor
b184a75Claude1/**
2 * Block J28 — Issue title similarity tests. Pure ranker + route smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 MIN_TOKEN_LENGTH,
8 STOPWORDS,
9 DEFAULT_MIN_SCORE,
10 DEFAULT_LIMIT,
11 tokeniseTitle,
12 jaccard,
13 rankCandidates,
14 findSimilar,
15 formatSimilarityPercent,
16 __internal,
17 type SimilarityCandidate,
18} from "../lib/issue-similarity";
19
20describe("issue-similarity — tokeniseTitle", () => {
21 it("empty / non-string input returns empty set", () => {
22 expect(tokeniseTitle("")).toEqual(new Set());
23 expect(tokeniseTitle(null)).toEqual(new Set());
24 expect(tokeniseTitle(undefined)).toEqual(new Set());
25 expect(tokeniseTitle(42)).toEqual(new Set());
26 expect(tokeniseTitle({})).toEqual(new Set());
27 });
28
29 it("lowercases and splits on whitespace", () => {
30 const t = tokeniseTitle("Add Auth To API");
31 expect(t.has("add")).toBe(true);
32 expect(t.has("auth")).toBe(true);
33 expect(t.has("api")).toBe(true);
34 // "to" is a stopword
35 expect(t.has("to")).toBe(false);
36 });
37
38 it("strips punctuation", () => {
39 const t = tokeniseTitle("fix: crash on window.resize()");
40 expect(t.has("fix")).toBe(true);
41 expect(t.has("crash")).toBe(true);
42 expect(t.has("window")).toBe(true);
43 expect(t.has("resize")).toBe(true);
44 });
45
46 it("drops stopwords", () => {
47 const t = tokeniseTitle("the quick and the dead");
48 expect(t.has("the")).toBe(false);
49 expect(t.has("and")).toBe(false);
50 expect(t.has("quick")).toBe(true);
51 expect(t.has("dead")).toBe(true);
52 });
53
54 it("drops tokens shorter than MIN_TOKEN_LENGTH", () => {
55 const t = tokeniseTitle("x y z hello");
56 expect(t.has("x")).toBe(false);
57 expect(t.has("y")).toBe(false);
58 expect(t.has("z")).toBe(false);
59 expect(t.has("hello")).toBe(true);
60 });
61
62 it("handles Unicode letters", () => {
63 const t = tokeniseTitle("café français resumé");
64 expect(t.has("café")).toBe(true);
65 expect(t.has("français")).toBe(true);
66 expect(t.has("resumé")).toBe(true);
67 });
68
69 it("dedups via Set", () => {
70 const t = tokeniseTitle("crash crash crash boom crash");
71 expect(t.size).toBe(2);
72 expect(t.has("crash")).toBe(true);
73 expect(t.has("boom")).toBe(true);
74 });
75
76 it("preserves hyphens + underscores inside tokens", () => {
77 const t = tokeniseTitle("check-ref-format cors_policy");
78 expect(t.has("check-ref-format")).toBe(true);
79 expect(t.has("cors_policy")).toBe(true);
80 });
81
82 it("retains digits", () => {
83 const t = tokeniseTitle("support HTTP2 and IPv6");
84 expect(t.has("http2")).toBe(true);
85 expect(t.has("ipv6")).toBe(true);
86 });
87});
88
89describe("issue-similarity — jaccard", () => {
90 it("returns 0 for two empty sets", () => {
91 expect(jaccard(new Set(), new Set())).toBe(0);
92 });
93 it("returns 1 for identical sets", () => {
94 expect(jaccard(new Set(["a", "b"]), new Set(["a", "b"]))).toBe(1);
95 });
96 it("returns 0 for disjoint sets", () => {
97 expect(jaccard(new Set(["a"]), new Set(["b"]))).toBe(0);
98 });
99 it("half overlap is 1/3", () => {
100 // {a,b} ∩ {b,c} = {b}; union = {a,b,c}; 1/3
101 expect(jaccard(new Set(["a", "b"]), new Set(["b", "c"]))).toBeCloseTo(
102 1 / 3,
103 6
104 );
105 });
106 it("subset: {a} ⊂ {a,b} → 1/2", () => {
107 expect(jaccard(new Set(["a"]), new Set(["a", "b"]))).toBe(0.5);
108 });
109 it("order of arguments doesn't matter", () => {
110 const a = new Set(["x", "y", "z"]);
111 const b = new Set(["y", "z", "w"]);
112 expect(jaccard(a, b)).toBe(jaccard(b, a));
113 });
114});
115
116describe("issue-similarity — rankCandidates", () => {
117 const candidates: SimilarityCandidate[] = [
118 { id: "1", number: 1, title: "Crash on startup", state: "open" },
119 { id: "2", number: 2, title: "Application crashes on boot", state: "open" },
120 { id: "3", number: 3, title: "Dark mode toggle", state: "open" },
121 { id: "4", number: 4, title: "Crash on startup with fresh install", state: "closed" },
122 { id: "5", number: 5, title: "", state: "open" },
123 ];
124
125 it("empty title returns empty", () => {
126 expect(rankCandidates("", candidates)).toEqual([]);
127 });
128
129 it("ranks by score descending", () => {
130 const r = rankCandidates("crash on startup", candidates);
131 expect(r.length).toBeGreaterThan(0);
132 // #1 is an exact token-match → score 1.0
133 expect(r[0]!.id).toBe("1");
134 expect(r[0]!.score).toBe(1);
135 });
136
137 it("minScore drops weak matches", () => {
138 const r = rankCandidates("dark", candidates, { minScore: 0.5 });
139 // "dark" matches "Dark mode toggle" with 1/3 only → under threshold
140 expect(r.length).toBe(0);
141 });
142
143 it("limit caps the result count", () => {
144 const r = rankCandidates("crash startup", candidates, {
145 limit: 1,
146 minScore: 0,
147 });
148 expect(r).toHaveLength(1);
149 });
150
151 it("excludeId skips a candidate by primary key", () => {
152 const r = rankCandidates("crash on startup", candidates, {
153 excludeId: "1",
154 minScore: 0,
155 });
156 expect(r.some((x) => x.id === "1")).toBe(false);
157 });
158
159 it("excludeNumber skips a candidate by issue number", () => {
160 const r = rankCandidates("crash on startup", candidates, {
161 excludeNumber: 1,
162 minScore: 0,
163 });
164 expect(r.some((x) => x.number === 1)).toBe(false);
165 });
166
167 it("state filter restricts candidates", () => {
168 const r = rankCandidates("crash startup", candidates, {
169 state: "open",
170 minScore: 0,
171 });
172 expect(r.every((x) => x.state === "open")).toBe(true);
173 });
174
175 it("ignores candidates whose title yields no tokens", () => {
176 const r = rankCandidates("crash", candidates, { minScore: 0 });
177 expect(r.some((x) => x.id === "5")).toBe(false);
178 });
179
180 it("tie-breaks by createdAt desc", () => {
181 const list: SimilarityCandidate[] = [
182 { id: "old", number: 10, title: "foo bar", createdAt: "2025-01-01" },
183 { id: "new", number: 20, title: "foo bar", createdAt: "2025-06-01" },
184 ];
185 const r = rankCandidates("foo bar", list, { minScore: 0 });
186 expect(r[0]!.id).toBe("new");
187 expect(r[1]!.id).toBe("old");
188 });
189
190 it("falls back to number-desc when createdAt equal", () => {
191 const list: SimilarityCandidate[] = [
192 { id: "a", number: 10, title: "foo bar" },
193 { id: "b", number: 20, title: "foo bar" },
194 ];
195 const r = rankCandidates("foo bar", list, { minScore: 0 });
196 expect(r[0]!.number).toBe(20);
197 });
198
199 it("limit=0 short-circuits to empty", () => {
200 const r = rankCandidates("crash", candidates, { limit: 0, minScore: 0 });
201 expect(r).toEqual([]);
202 });
203
204 it("never mutates the candidate list", () => {
205 const snap = candidates.map((c) => c.id).join(",");
206 rankCandidates("crash startup", candidates);
207 expect(candidates.map((c) => c.id).join(",")).toBe(snap);
208 });
209
210 it("stopword-only title returns empty", () => {
211 const r = rankCandidates("the and or", candidates);
212 expect(r).toEqual([]);
213 });
214
215 it("uses DEFAULT_MIN_SCORE + DEFAULT_LIMIT when opts omitted", () => {
216 expect(DEFAULT_MIN_SCORE).toBe(0.15);
217 expect(DEFAULT_LIMIT).toBe(5);
218 const r = rankCandidates("crash on startup", candidates);
219 expect(r.length).toBeLessThanOrEqual(5);
220 for (const item of r) expect(item.score).toBeGreaterThanOrEqual(0.15);
221 });
222});
223
224describe("issue-similarity — findSimilar", () => {
225 it("alias of rankCandidates", () => {
226 const cs: SimilarityCandidate[] = [
227 { id: "x", number: 1, title: "hello world" },
228 ];
229 expect(findSimilar("hello", cs, { minScore: 0 })).toEqual(
230 rankCandidates("hello", cs, { minScore: 0 })
231 );
232 });
233});
234
235describe("issue-similarity — formatSimilarityPercent", () => {
236 it("formats with percent suffix", () => {
237 expect(formatSimilarityPercent(0)).toBe("0%");
238 expect(formatSimilarityPercent(0.5)).toBe("50%");
239 expect(formatSimilarityPercent(1)).toBe("100%");
240 });
241 it("rounds half-up", () => {
242 expect(formatSimilarityPercent(0.456)).toBe("46%");
243 expect(formatSimilarityPercent(0.454)).toBe("45%");
244 });
245 it("clamps out-of-range to [0,100]", () => {
246 expect(formatSimilarityPercent(-0.5)).toBe("0%");
247 expect(formatSimilarityPercent(2)).toBe("100%");
248 });
249 it("non-finite → 0%", () => {
250 expect(formatSimilarityPercent(Number.NaN)).toBe("0%");
251 expect(formatSimilarityPercent(Number.POSITIVE_INFINITY)).toBe("0%");
252 });
253});
254
255describe("issue-similarity — constants", () => {
256 it("MIN_TOKEN_LENGTH default", () => {
257 expect(MIN_TOKEN_LENGTH).toBe(2);
258 });
259 it("STOPWORDS contains common fillers", () => {
260 for (const w of ["the", "and", "is", "it", "for"]) {
261 expect(STOPWORDS.has(w)).toBe(true);
262 }
263 expect(STOPWORDS.has("crash")).toBe(false);
264 });
265});
266
267describe("issue-similarity — routes", () => {
268 it("GET /:o/:r/issues/similar.json is guarded (never 500)", async () => {
269 const { default: app } = await import("../app");
270 const res = await app.request(
271 "/alice/repo/issues/similar.json?q=crash+startup"
272 );
273 expect([200, 400, 404]).toContain(res.status);
274 });
275
276 it("GET /:o/:r/issues/:n/similar is guarded (never 500)", async () => {
277 const { default: app } = await import("../app");
278 const res = await app.request("/alice/repo/issues/1/similar");
279 expect([200, 404]).toContain(res.status);
280 });
281});
282
283describe("issue-similarity — __internal parity", () => {
284 it("re-exports helpers", () => {
285 expect(__internal.tokeniseTitle).toBe(tokeniseTitle);
286 expect(__internal.jaccard).toBe(jaccard);
287 expect(__internal.rankCandidates).toBe(rankCandidates);
288 expect(__internal.findSimilar).toBe(findSimilar);
289 expect(__internal.formatSimilarityPercent).toBe(formatSimilarityPercent);
290 expect(__internal.MIN_TOKEN_LENGTH).toBe(MIN_TOKEN_LENGTH);
291 expect(__internal.STOPWORDS).toBe(STOPWORDS);
292 expect(__internal.DEFAULT_MIN_SCORE).toBe(DEFAULT_MIN_SCORE);
293 expect(__internal.DEFAULT_LIMIT).toBe(DEFAULT_LIMIT);
294 expect(typeof __internal.toTime).toBe("function");
295 });
296});