@fixy/core 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omc/state/idle-notif-cooldown.json +3 -0
- package/.omc/state/last-tool-error.json +7 -0
- package/dist/__tests__/fixy-commands.test.js +172 -3
- package/dist/__tests__/fixy-commands.test.js.map +1 -1
- package/dist/fixy-commands.d.ts +1 -0
- package/dist/fixy-commands.d.ts.map +1 -1
- package/dist/fixy-commands.js +186 -3
- package/dist/fixy-commands.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -2
- package/dist/store.js.map +1 -1
- package/package.json +5 -2
- package/src/__tests__/fixy-commands.test.ts +195 -3
- package/src/__tests__/slugify.test.ts +290 -0
- package/src/fixy-commands.ts +233 -6
- package/src/index.ts +2 -0
- package/src/slugify.ts +34 -0
- package/src/store.ts +2 -1
|
@@ -127,18 +127,210 @@ describe('FixyCommandRunner', () => {
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
// -------------------------------------------------------------------------
|
|
130
|
-
// Test 3: /all
|
|
130
|
+
// Test 3: /all without prompt — shows usage message
|
|
131
131
|
// -------------------------------------------------------------------------
|
|
132
|
-
it('/all
|
|
132
|
+
it('/all without prompt — shows usage message', async () => {
|
|
133
|
+
await runner.run(makeCtx({ rest: '/all' }));
|
|
134
|
+
|
|
135
|
+
const fresh = await store.getThread(thread.id, thread.projectRoot);
|
|
136
|
+
const sysMsg = fresh.messages.find(
|
|
137
|
+
(m) => m.role === 'system' && m.content.includes('/all requires a prompt'),
|
|
138
|
+
);
|
|
139
|
+
expect(sysMsg).toBeDefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
// Test 3b: /all with no adapters — shows error
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
it('/all with no adapters — shows error', async () => {
|
|
133
146
|
await runner.run(makeCtx({ rest: '/all build something' }));
|
|
134
147
|
|
|
135
148
|
const fresh = await store.getThread(thread.id, thread.projectRoot);
|
|
136
149
|
const sysMsg = fresh.messages.find(
|
|
137
|
-
(m) => m.role === 'system' && m.content.includes('
|
|
150
|
+
(m) => m.role === 'system' && m.content.includes('at least one registered adapter'),
|
|
138
151
|
);
|
|
139
152
|
expect(sysMsg).toBeDefined();
|
|
140
153
|
});
|
|
141
154
|
|
|
155
|
+
// -------------------------------------------------------------------------
|
|
156
|
+
// Test 3c: /all solo mode — single adapter skips discussion
|
|
157
|
+
// -------------------------------------------------------------------------
|
|
158
|
+
it('/all solo mode — single adapter runs plan+execute without discussion', async () => {
|
|
159
|
+
let callCount = 0;
|
|
160
|
+
const soloAdapter = createStubAdapter('claude', 'Claude', async (ctx) => {
|
|
161
|
+
callCount++;
|
|
162
|
+
// First call: plan breakdown → return numbered list
|
|
163
|
+
if (callCount === 1) {
|
|
164
|
+
return {
|
|
165
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
166
|
+
summary: '1. Create auth module\n2. Add login endpoint',
|
|
167
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// Second call: worker execution
|
|
171
|
+
return {
|
|
172
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
173
|
+
summary: 'Implemented auth module and login endpoint',
|
|
174
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
registry.register(soloAdapter);
|
|
178
|
+
thread.workerModel = 'claude';
|
|
179
|
+
|
|
180
|
+
const logs: string[] = [];
|
|
181
|
+
await runner.run(makeCtx({
|
|
182
|
+
rest: '/all build auth',
|
|
183
|
+
onLog: (_s, msg) => logs.push(msg),
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
expect(logs.some((l) => l.includes('Solo mode'))).toBe(true);
|
|
187
|
+
expect(logs.some((l) => l.includes('Phase 2'))).toBe(true);
|
|
188
|
+
expect(logs.some((l) => l.includes('Phase 3'))).toBe(true);
|
|
189
|
+
|
|
190
|
+
const fresh = await store.getThread(thread.id, thread.projectRoot);
|
|
191
|
+
const completionMsg = fresh.messages.find(
|
|
192
|
+
(m) => m.role === 'system' && m.content.includes('collaboration complete'),
|
|
193
|
+
);
|
|
194
|
+
expect(completionMsg).toBeDefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// -------------------------------------------------------------------------
|
|
198
|
+
// Test 3d: /all multi-adapter — full discussion + worker + review
|
|
199
|
+
// -------------------------------------------------------------------------
|
|
200
|
+
it('/all multi-adapter — runs discussion, plan, worker execution, and review', async () => {
|
|
201
|
+
// Thinker agrees immediately
|
|
202
|
+
const thinkerAdapter = createStubAdapter('codex', 'Codex', async () => ({
|
|
203
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
204
|
+
summary: 'I agree with the plan. LGTM.\n1. Create util function\n2. Add tests',
|
|
205
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
let workerCalls = 0;
|
|
209
|
+
const workerAdapterObj = createStubAdapter('claude', 'Claude', async () => {
|
|
210
|
+
workerCalls++;
|
|
211
|
+
return {
|
|
212
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
213
|
+
summary: 'Done implementing the requested changes.',
|
|
214
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
registry.register(workerAdapterObj);
|
|
219
|
+
registry.register(thinkerAdapter);
|
|
220
|
+
thread.workerModel = 'claude';
|
|
221
|
+
|
|
222
|
+
const logs: string[] = [];
|
|
223
|
+
await runner.run(makeCtx({
|
|
224
|
+
rest: '/all build a utility',
|
|
225
|
+
onLog: (_s, msg) => logs.push(msg),
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
// Discussion should have ended early due to agreement
|
|
229
|
+
expect(logs.some((l) => l.includes('Phase 1'))).toBe(true);
|
|
230
|
+
expect(logs.some((l) => l.includes('Phase 2'))).toBe(true);
|
|
231
|
+
expect(logs.some((l) => l.includes('Phase 3'))).toBe(true);
|
|
232
|
+
expect(logs.some((l) => l.includes('Phase 5'))).toBe(true);
|
|
233
|
+
expect(workerCalls).toBeGreaterThanOrEqual(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
// Test 3e: /all review finds issues — worker gets fix attempt
|
|
238
|
+
// -------------------------------------------------------------------------
|
|
239
|
+
it('/all review issues — worker retries when thinker flags issues', async () => {
|
|
240
|
+
let thinkerCalls = 0;
|
|
241
|
+
const thinkerAdapter = createStubAdapter('codex', 'Codex', async () => {
|
|
242
|
+
thinkerCalls++;
|
|
243
|
+
// Discussion: agree immediately
|
|
244
|
+
if (thinkerCalls <= 1) {
|
|
245
|
+
return {
|
|
246
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
247
|
+
summary: 'I agree.\n1. Fix the bug',
|
|
248
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
// Plan breakdown
|
|
252
|
+
if (thinkerCalls === 2) {
|
|
253
|
+
return {
|
|
254
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
255
|
+
summary: '1. Fix the bug',
|
|
256
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
// First review: flag issue
|
|
260
|
+
if (thinkerCalls === 3) {
|
|
261
|
+
return {
|
|
262
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
263
|
+
summary: 'ISSUES: Missing error handling',
|
|
264
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// Second review: approve
|
|
268
|
+
return {
|
|
269
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
270
|
+
summary: 'APPROVED',
|
|
271
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
let workerCalls = 0;
|
|
276
|
+
const workerAdapterObj = createStubAdapter('claude', 'Claude', async () => {
|
|
277
|
+
workerCalls++;
|
|
278
|
+
return {
|
|
279
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
280
|
+
summary: `Worker output attempt ${workerCalls}`,
|
|
281
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
registry.register(workerAdapterObj);
|
|
286
|
+
registry.register(thinkerAdapter);
|
|
287
|
+
thread.workerModel = 'claude';
|
|
288
|
+
|
|
289
|
+
const logs: string[] = [];
|
|
290
|
+
await runner.run(makeCtx({
|
|
291
|
+
rest: '/all fix the bug',
|
|
292
|
+
onLog: (_s, msg) => logs.push(msg),
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
// Worker should have been called at least twice (initial + fix)
|
|
296
|
+
expect(workerCalls).toBeGreaterThanOrEqual(2);
|
|
297
|
+
expect(logs.some((l) => l.includes('Phase 4'))).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// -------------------------------------------------------------------------
|
|
301
|
+
// Test 3f: /all caps TODOs at 20
|
|
302
|
+
// -------------------------------------------------------------------------
|
|
303
|
+
it('/all caps TODO list at 20 items', async () => {
|
|
304
|
+
const longList = Array.from({ length: 25 }, (_, i) => `${i + 1}. Task item ${i + 1}`).join('\n');
|
|
305
|
+
let callCount = 0;
|
|
306
|
+
const soloAdapter = createStubAdapter('claude', 'Claude', async () => {
|
|
307
|
+
callCount++;
|
|
308
|
+
if (callCount === 1) {
|
|
309
|
+
return {
|
|
310
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
311
|
+
summary: longList,
|
|
312
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
exitCode: 0, signal: null, timedOut: false,
|
|
317
|
+
summary: 'Done',
|
|
318
|
+
session: null, patches: [], warnings: [], errorMessage: null,
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
registry.register(soloAdapter);
|
|
322
|
+
thread.workerModel = 'claude';
|
|
323
|
+
|
|
324
|
+
const logs: string[] = [];
|
|
325
|
+
await runner.run(makeCtx({
|
|
326
|
+
rest: '/all big task',
|
|
327
|
+
onLog: (_s, msg) => logs.push(msg),
|
|
328
|
+
}));
|
|
329
|
+
|
|
330
|
+
// Should report exactly 20 TODOs
|
|
331
|
+
expect(logs.some((l) => l.includes('20 TODO items'))).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
|
|
142
334
|
// -------------------------------------------------------------------------
|
|
143
335
|
// Test 4: /settings — returns stub message
|
|
144
336
|
// -------------------------------------------------------------------------
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { slugify } from '../slugify.js';
|
|
3
|
+
|
|
4
|
+
describe('slugify — basic ASCII', () => {
|
|
5
|
+
it('lowercases the input', () => {
|
|
6
|
+
expect(slugify('Hello World')).toBe('hello-world');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('removes punctuation', () => {
|
|
10
|
+
expect(slugify('Hello, World!')).toBe('hello-world');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('collapses multiple whitespace characters into a single hyphen', () => {
|
|
14
|
+
expect(slugify('foo bar')).toBe('foo-bar');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('preserves digits', () => {
|
|
18
|
+
expect(slugify('foo 123 bar')).toBe('foo-123-bar');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('collapses repeated non-alphanumeric chars into one hyphen', () => {
|
|
22
|
+
expect(slugify('a---b')).toBe('a-b');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('trims leading hyphens', () => {
|
|
26
|
+
expect(slugify('---hello')).toBe('hello');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('trims trailing hyphens', () => {
|
|
30
|
+
expect(slugify('hello---')).toBe('hello');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('trims both leading and trailing hyphens', () => {
|
|
34
|
+
expect(slugify('---hello---')).toBe('hello');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns empty string when only punctuation is given', () => {
|
|
38
|
+
expect(slugify('!!!')).toBe('');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns empty string for an empty input', () => {
|
|
42
|
+
expect(slugify('')).toBe('');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('removes apostrophes so contractions collapse without a separator', () => {
|
|
46
|
+
expect(slugify("don't stop")).toBe('dont-stop');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('handles a slug that is already clean', () => {
|
|
50
|
+
expect(slugify('hello-world')).toBe('hello-world');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles input that is purely digits', () => {
|
|
54
|
+
expect(slugify('123')).toBe('123');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('output contains only lowercase letters, digits, and hyphens', () => {
|
|
58
|
+
const result = slugify('Hello, World! 123 -- foo@bar.baz');
|
|
59
|
+
expect(result).toMatch(/^[a-z0-9-]+$/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('slugify — diacritics and transliteration map', () => {
|
|
64
|
+
it('strips diacritics via NFKD decomposition', () => {
|
|
65
|
+
expect(slugify('naïve café')).toBe('naive-cafe');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles "Crème brûlée"', () => {
|
|
69
|
+
expect(slugify('Crème brûlée')).toBe('creme-brulee');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('transliterates ß → ss ("Straße")', () => {
|
|
73
|
+
expect(slugify('Straße')).toBe('strasse');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('transliterates Æ → ae and Œ → oe ("Æther Œuvre")', () => {
|
|
77
|
+
expect(slugify('Æther Œuvre')).toBe('aether-oeuvre');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('transliterates æ → ae', () => {
|
|
81
|
+
expect(slugify('æon')).toBe('aeon');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('transliterates œ → oe', () => {
|
|
85
|
+
expect(slugify('œuvre')).toBe('oeuvre');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('transliterates ø / Ø → o', () => {
|
|
89
|
+
expect(slugify('ø')).toBe('o');
|
|
90
|
+
expect(slugify('Ø')).toBe('o');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('transliterates đ / Đ → d', () => {
|
|
94
|
+
expect(slugify('đuro')).toBe('duro');
|
|
95
|
+
expect(slugify('Đuro')).toBe('duro');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('transliterates ð / Ð → d', () => {
|
|
99
|
+
expect(slugify('ðór')).toBe('dor');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('transliterates þ / Þ → th', () => {
|
|
103
|
+
expect(slugify('þorn')).toBe('thorn');
|
|
104
|
+
expect(slugify('Þorn')).toBe('thorn');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('transliterates ł / Ł → l', () => {
|
|
108
|
+
expect(slugify('Łódź')).toBe('lodz');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns empty string when only non-slug-safe unicode chars remain', () => {
|
|
112
|
+
// Characters with no transliteration and no ASCII base (e.g. CJK)
|
|
113
|
+
expect(slugify('日本語')).toBe('');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('output is strictly ASCII for transliterated input', () => {
|
|
117
|
+
const result = slugify('Crème brûlée Straße Æther Œuvre');
|
|
118
|
+
expect(result).toMatch(/^[a-z0-9-]+$/);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('slugify — separators, underscores, slashes', () => {
|
|
123
|
+
it('converts underscores to hyphens', () => {
|
|
124
|
+
expect(slugify('foo_bar')).toBe('foo-bar');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('converts multiple underscores to a single hyphen', () => {
|
|
128
|
+
expect(slugify('foo___bar')).toBe('foo-bar');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('converts forward slashes to hyphens', () => {
|
|
132
|
+
expect(slugify('foo/bar')).toBe('foo-bar');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('converts backslashes to hyphens', () => {
|
|
136
|
+
expect(slugify('foo\\bar')).toBe('foo-bar');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('converts dots used as separators to hyphens', () => {
|
|
140
|
+
expect(slugify('foo.bar.baz')).toBe('foo-bar-baz');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('converts pipes to hyphens', () => {
|
|
144
|
+
expect(slugify('foo|bar')).toBe('foo-bar');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('converts colons to hyphens', () => {
|
|
148
|
+
expect(slugify('foo:bar')).toBe('foo-bar');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('collapses mixed separators (slash + underscore + space) into one hyphen', () => {
|
|
152
|
+
expect(slugify('foo / _bar')).toBe('foo-bar');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('preserves an already-clean hyphenated slug unchanged', () => {
|
|
156
|
+
expect(slugify('already-clean-slug')).toBe('already-clean-slug');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('preserves hyphenated slug with numbers', () => {
|
|
160
|
+
expect(slugify('step-1-of-3')).toBe('step-1-of-3');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('slugify — apostrophes', () => {
|
|
165
|
+
it('removes straight apostrophe in contraction without inserting a hyphen', () => {
|
|
166
|
+
expect(slugify("it's")).toBe('its');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('removes Unicode left single quotation mark (U+2018)', () => {
|
|
170
|
+
expect(slugify('\u2018hello\u2019')).toBe('hello');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('removes Unicode right single quotation mark (U+2019)', () => {
|
|
174
|
+
expect(slugify("won\u2019t")).toBe('wont');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('removes modifier letter apostrophe (U+02BC)', () => {
|
|
178
|
+
expect(slugify('o\u02BCclock')).toBe('oclock');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('handles multiple apostrophes in a row without producing hyphens', () => {
|
|
182
|
+
expect(slugify("''hello''")).toBe('hello');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('slugify — emojis', () => {
|
|
187
|
+
it('strips a leading emoji', () => {
|
|
188
|
+
expect(slugify('🚀 launch')).toBe('launch');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('strips a trailing emoji', () => {
|
|
192
|
+
expect(slugify('launch 🚀')).toBe('launch');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('strips an emoji between words without doubling hyphens', () => {
|
|
196
|
+
expect(slugify('foo 🔥 bar')).toBe('foo-bar');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('strips an emoji adjacent to a word without inserting a hyphen', () => {
|
|
200
|
+
expect(slugify('foo🔥bar')).toBe('foobar');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('returns empty string when input is only emojis', () => {
|
|
204
|
+
expect(slugify('🚀🔥💡')).toBe('');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('slugify — edge cases', () => {
|
|
209
|
+
it('returns empty string for an empty input ""', () => {
|
|
210
|
+
expect(slugify('')).toBe('');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('returns empty string for whitespace-only input', () => {
|
|
214
|
+
expect(slugify(' ')).toBe('');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('returns empty string for tab and newline input', () => {
|
|
218
|
+
expect(slugify('\t\n\r')).toBe('');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('returns empty string for all-symbol input (mixed punctuation)', () => {
|
|
222
|
+
expect(slugify('!@#$%^&*()')).toBe('');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns empty string for Cyrillic input with no transliteration', () => {
|
|
226
|
+
expect(slugify('Привет')).toBe('');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('returns empty string for Arabic input', () => {
|
|
230
|
+
expect(slugify('مرحبا')).toBe('');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('returns empty string for Hebrew input', () => {
|
|
234
|
+
expect(slugify('שלום')).toBe('');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('returns empty string for CJK input', () => {
|
|
238
|
+
expect(slugify('日本語')).toBe('');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('handles a single ASCII letter', () => {
|
|
242
|
+
expect(slugify('a')).toBe('a');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('handles a single digit', () => {
|
|
246
|
+
expect(slugify('9')).toBe('9');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('slugify — output invariants', () => {
|
|
251
|
+
const cases = [
|
|
252
|
+
'Hello, World!',
|
|
253
|
+
' leading and trailing ',
|
|
254
|
+
'---hyphens---',
|
|
255
|
+
'a---b---c',
|
|
256
|
+
'foo / bar \\ baz',
|
|
257
|
+
'foo_bar_baz',
|
|
258
|
+
'Crème brûlée Straße',
|
|
259
|
+
'🚀 rockets 🔥 fire',
|
|
260
|
+
'it\u2019s a test',
|
|
261
|
+
'!!!',
|
|
262
|
+
'',
|
|
263
|
+
'日本語',
|
|
264
|
+
'UPPER CASE INPUT',
|
|
265
|
+
'mixed123Numbers',
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
for (const input of cases) {
|
|
269
|
+
it(`output for "${input}" never has repeated, leading, or trailing hyphens`, () => {
|
|
270
|
+
const result = slugify(input);
|
|
271
|
+
if (result.length > 0) {
|
|
272
|
+
expect(result).not.toMatch(/^-/);
|
|
273
|
+
expect(result).not.toMatch(/-$/);
|
|
274
|
+
expect(result).not.toMatch(/--/);
|
|
275
|
+
expect(result).toMatch(/^[a-z0-9-]+$/);
|
|
276
|
+
} else {
|
|
277
|
+
expect(result).toBe('');
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
it('valid slug input is idempotent (slugify(slugify(x)) === slugify(x))', () => {
|
|
283
|
+
const inputs = ['Hello World', 'foo_bar', 'Crème brûlée', '🚀 launch', "don't stop"];
|
|
284
|
+
for (const input of inputs) {
|
|
285
|
+
const once = slugify(input);
|
|
286
|
+
const twice = slugify(once);
|
|
287
|
+
expect(twice).toBe(once);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|