@shaykec/claude-teach 0.2.0
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/README.md +69 -0
- package/commands/teach.md +78 -0
- package/engine/authoring.md +150 -0
- package/engine/teach-command.md +76 -0
- package/package.json +28 -0
- package/src/author.js +355 -0
- package/src/bridge-server.js +60 -0
- package/src/cli.js +512 -0
- package/src/gamification.js +176 -0
- package/src/gamification.test.js +509 -0
- package/src/marketplace.js +349 -0
- package/src/progress.js +157 -0
- package/src/progress.test.js +360 -0
- package/src/registry.js +201 -0
- package/src/registry.test.js +309 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { BELTS, getBelt, getNextBelt, formatDashboard, suggestNext } from './gamification.js';
|
|
3
|
+
|
|
4
|
+
/* ================================================================== */
|
|
5
|
+
/* BELTS constant */
|
|
6
|
+
/* ================================================================== */
|
|
7
|
+
|
|
8
|
+
describe('BELTS', () => {
|
|
9
|
+
it('should contain exactly 7 belts', () => {
|
|
10
|
+
expect(BELTS).toHaveLength(7);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should have ascending minXP thresholds', () => {
|
|
14
|
+
for (let i = 1; i < BELTS.length; i++) {
|
|
15
|
+
expect(BELTS[i].minXP).toBeGreaterThan(BELTS[i - 1].minXP);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should have the correct belt names in order', () => {
|
|
20
|
+
const names = BELTS.map(b => b.name);
|
|
21
|
+
expect(names).toEqual(['White', 'Yellow', 'Green', 'Blue', 'Purple', 'Brown', 'Black']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should start with White at 0 XP', () => {
|
|
25
|
+
expect(BELTS[0]).toMatchObject({ name: 'White', minXP: 0 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should end with Black at 3000 XP', () => {
|
|
29
|
+
expect(BELTS[6]).toMatchObject({ name: 'Black', minXP: 3000 });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('each belt should have a name, minXP, and badge', () => {
|
|
33
|
+
for (const belt of BELTS) {
|
|
34
|
+
expect(belt).toHaveProperty('name');
|
|
35
|
+
expect(belt).toHaveProperty('minXP');
|
|
36
|
+
expect(belt).toHaveProperty('badge');
|
|
37
|
+
expect(typeof belt.name).toBe('string');
|
|
38
|
+
expect(typeof belt.minXP).toBe('number');
|
|
39
|
+
expect(typeof belt.badge).toBe('string');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/* ================================================================== */
|
|
45
|
+
/* getBelt(xp) */
|
|
46
|
+
/* ================================================================== */
|
|
47
|
+
|
|
48
|
+
describe('getBelt', () => {
|
|
49
|
+
it('should return White belt for 0 XP', () => {
|
|
50
|
+
const belt = getBelt(0);
|
|
51
|
+
expect(belt.name).toBe('White');
|
|
52
|
+
expect(belt.display).toContain('White');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should return White belt for 49 XP', () => {
|
|
56
|
+
const belt = getBelt(49);
|
|
57
|
+
expect(belt.name).toBe('White');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return Yellow belt for exactly 50 XP', () => {
|
|
61
|
+
const belt = getBelt(50);
|
|
62
|
+
expect(belt.name).toBe('Yellow');
|
|
63
|
+
expect(belt.display).toContain('Yellow');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return Green belt for 150 XP', () => {
|
|
67
|
+
const belt = getBelt(150);
|
|
68
|
+
expect(belt.name).toBe('Green');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should return Blue belt for 400 XP', () => {
|
|
72
|
+
const belt = getBelt(400);
|
|
73
|
+
expect(belt.name).toBe('Blue');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return Purple belt for 800 XP', () => {
|
|
77
|
+
const belt = getBelt(800);
|
|
78
|
+
expect(belt.name).toBe('Purple');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should return Brown belt for 1500 XP', () => {
|
|
82
|
+
const belt = getBelt(1500);
|
|
83
|
+
expect(belt.name).toBe('Brown');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return Black belt (1st Dan) for exactly 3000 XP', () => {
|
|
87
|
+
const belt = getBelt(3000);
|
|
88
|
+
expect(belt.name).toBe('Black');
|
|
89
|
+
expect(belt.dan).toBe(1);
|
|
90
|
+
expect(belt.display).toContain('1st Dan');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should return Black belt (2nd Dan) for 6000 XP', () => {
|
|
94
|
+
const belt = getBelt(6000);
|
|
95
|
+
expect(belt.name).toBe('Black');
|
|
96
|
+
expect(belt.dan).toBe(2);
|
|
97
|
+
expect(belt.display).toContain('2nd Dan');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should return Black belt (3rd Dan) for 12000 XP', () => {
|
|
101
|
+
const belt = getBelt(12000);
|
|
102
|
+
expect(belt.name).toBe('Black');
|
|
103
|
+
expect(belt.dan).toBe(3);
|
|
104
|
+
expect(belt.display).toContain('3rd Dan');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should return Black belt (4th Dan) for 24000 XP', () => {
|
|
108
|
+
const belt = getBelt(24000);
|
|
109
|
+
expect(belt.name).toBe('Black');
|
|
110
|
+
expect(belt.dan).toBe(4);
|
|
111
|
+
expect(belt.display).toContain('4th Dan');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should return Black belt (5th Dan) for 48000 XP', () => {
|
|
115
|
+
const belt = getBelt(48000);
|
|
116
|
+
expect(belt.name).toBe('Black');
|
|
117
|
+
expect(belt.dan).toBe(5);
|
|
118
|
+
expect(belt.display).toContain('5th Dan');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should return White belt for negative XP', () => {
|
|
122
|
+
const belt = getBelt(-10);
|
|
123
|
+
expect(belt.name).toBe('White');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should stay at Brown belt for 2999 XP', () => {
|
|
127
|
+
const belt = getBelt(2999);
|
|
128
|
+
expect(belt.name).toBe('Brown');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should include badge in display for non-Black belts', () => {
|
|
132
|
+
const belt = getBelt(150);
|
|
133
|
+
expect(belt.display).toContain(belt.badge);
|
|
134
|
+
expect(belt.display).toContain('Belt');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should return a display string for every XP level', () => {
|
|
138
|
+
for (const xp of [0, 50, 150, 400, 800, 1500, 3000, 6000]) {
|
|
139
|
+
const belt = getBelt(xp);
|
|
140
|
+
expect(typeof belt.display).toBe('string');
|
|
141
|
+
expect(belt.display.length).toBeGreaterThan(0);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/* ================================================================== */
|
|
147
|
+
/* getNextBelt(xp) */
|
|
148
|
+
/* ================================================================== */
|
|
149
|
+
|
|
150
|
+
describe('getNextBelt', () => {
|
|
151
|
+
it('should return Yellow as next belt for 0 XP', () => {
|
|
152
|
+
const next = getNextBelt(0);
|
|
153
|
+
expect(next.name).toBe('Yellow');
|
|
154
|
+
expect(next.minXP).toBe(50);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should return Green as next belt for 50 XP', () => {
|
|
158
|
+
const next = getNextBelt(50);
|
|
159
|
+
expect(next.name).toBe('Green');
|
|
160
|
+
expect(next.minXP).toBe(150);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should return Blue as next belt for 150 XP', () => {
|
|
164
|
+
const next = getNextBelt(150);
|
|
165
|
+
expect(next.name).toBe('Blue');
|
|
166
|
+
expect(next.minXP).toBe(400);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should return Purple as next belt for 400 XP', () => {
|
|
170
|
+
const next = getNextBelt(400);
|
|
171
|
+
expect(next.name).toBe('Purple');
|
|
172
|
+
expect(next.minXP).toBe(800);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should return Brown as next belt for 800 XP', () => {
|
|
176
|
+
const next = getNextBelt(800);
|
|
177
|
+
expect(next.name).toBe('Brown');
|
|
178
|
+
expect(next.minXP).toBe(1500);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should return Black as next belt for 1500 XP', () => {
|
|
182
|
+
const next = getNextBelt(1500);
|
|
183
|
+
expect(next.name).toBe('Black');
|
|
184
|
+
expect(next.minXP).toBe(3000);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should return next dan target for 3000 XP (already Black)', () => {
|
|
188
|
+
const next = getNextBelt(3000);
|
|
189
|
+
expect(next).not.toBeNull();
|
|
190
|
+
expect(next.minXP).toBe(6000);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should return next dan target for 6000 XP', () => {
|
|
194
|
+
const next = getNextBelt(6000);
|
|
195
|
+
expect(next).not.toBeNull();
|
|
196
|
+
expect(next.minXP).toBe(12000);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should return next dan target for 12000 XP', () => {
|
|
200
|
+
const next = getNextBelt(12000);
|
|
201
|
+
expect(next).not.toBeNull();
|
|
202
|
+
expect(next.minXP).toBe(24000);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should return next dan target for 24000 XP', () => {
|
|
206
|
+
const next = getNextBelt(24000);
|
|
207
|
+
expect(next).not.toBeNull();
|
|
208
|
+
expect(next.minXP).toBe(48000);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should return null when maxed out (48000+ XP)', () => {
|
|
212
|
+
const next = getNextBelt(48000);
|
|
213
|
+
expect(next).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should return null for very high XP beyond all dans', () => {
|
|
217
|
+
const next = getNextBelt(100000);
|
|
218
|
+
expect(next).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should return White for negative XP (since -5 < 0 which is White minXP)', () => {
|
|
222
|
+
const next = getNextBelt(-5);
|
|
223
|
+
// Negative XP is below White's minXP of 0, so White is the first belt with minXP > xp
|
|
224
|
+
expect(next.name).toBe('White');
|
|
225
|
+
expect(next.minXP).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should return Green for 49 XP (still in White, Yellow is next at 50 — but 49 < 50)', () => {
|
|
229
|
+
const next = getNextBelt(49);
|
|
230
|
+
expect(next.name).toBe('Yellow');
|
|
231
|
+
expect(next.minXP).toBe(50);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/* ================================================================== */
|
|
236
|
+
/* formatDashboard(progress, registry) */
|
|
237
|
+
/* ================================================================== */
|
|
238
|
+
|
|
239
|
+
describe('formatDashboard', () => {
|
|
240
|
+
it('should return a string containing the belt name and XP', () => {
|
|
241
|
+
const progress = { user: { xp: 200, modules_completed: 1 }, modules: {} };
|
|
242
|
+
const registry = { modules: [{ slug: 'git' }, { slug: 'hooks' }] };
|
|
243
|
+
const result = formatDashboard(progress, registry);
|
|
244
|
+
expect(typeof result).toBe('string');
|
|
245
|
+
expect(result).toContain('Green');
|
|
246
|
+
expect(result).toContain('200');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should handle zero XP progress', () => {
|
|
250
|
+
const progress = { user: { xp: 0, modules_completed: 0 }, modules: {} };
|
|
251
|
+
const registry = { modules: [] };
|
|
252
|
+
const result = formatDashboard(progress, registry);
|
|
253
|
+
expect(result).toContain('White');
|
|
254
|
+
expect(result).toContain('0');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should handle empty progress gracefully (missing user)', () => {
|
|
258
|
+
const progress = { modules: {} };
|
|
259
|
+
const registry = { modules: [] };
|
|
260
|
+
const result = formatDashboard(progress, registry);
|
|
261
|
+
expect(typeof result).toBe('string');
|
|
262
|
+
expect(result).toContain('White');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should handle completely empty progress object', () => {
|
|
266
|
+
const progress = {};
|
|
267
|
+
const registry = { modules: [] };
|
|
268
|
+
const result = formatDashboard(progress, registry);
|
|
269
|
+
expect(typeof result).toBe('string');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should handle null/undefined registry gracefully', () => {
|
|
273
|
+
const progress = { user: { xp: 0 }, modules: {} };
|
|
274
|
+
const result = formatDashboard(progress, null);
|
|
275
|
+
expect(typeof result).toBe('string');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should show module count from registry', () => {
|
|
279
|
+
const progress = { user: { xp: 0, modules_completed: 2 }, modules: {} };
|
|
280
|
+
const registry = { modules: [{}, {}, {}] };
|
|
281
|
+
const result = formatDashboard(progress, registry);
|
|
282
|
+
expect(result).toContain('2/3');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should show progress bar when next belt exists', () => {
|
|
286
|
+
const progress = { user: { xp: 25, modules_completed: 0 }, modules: {} };
|
|
287
|
+
const registry = { modules: [] };
|
|
288
|
+
const result = formatDashboard(progress, registry);
|
|
289
|
+
// The progress bar uses characters like [, ], %, █, ░
|
|
290
|
+
expect(result).toContain('%');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should show per-module breakdown when modules exist', () => {
|
|
294
|
+
const progress = {
|
|
295
|
+
user: { xp: 100 },
|
|
296
|
+
modules: {
|
|
297
|
+
git: { status: 'completed', xp_earned: 50, quiz_score: '4/5' },
|
|
298
|
+
hooks: { status: 'in-progress', xp_earned: 30 },
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
const registry = { modules: [] };
|
|
302
|
+
const result = formatDashboard(progress, registry);
|
|
303
|
+
expect(result).toContain('git');
|
|
304
|
+
expect(result).toContain('50 XP');
|
|
305
|
+
expect(result).toContain('quiz: 4/5');
|
|
306
|
+
expect(result).toContain('hooks');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should show completed icon for completed modules', () => {
|
|
310
|
+
const progress = {
|
|
311
|
+
user: { xp: 50 },
|
|
312
|
+
modules: {
|
|
313
|
+
git: { status: 'completed', xp_earned: 50 },
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
const registry = { modules: [] };
|
|
317
|
+
const result = formatDashboard(progress, registry);
|
|
318
|
+
// Completed modules get a checkmark
|
|
319
|
+
expect(result).toContain('✓');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should show in-progress icon for in-progress modules', () => {
|
|
323
|
+
const progress = {
|
|
324
|
+
user: { xp: 30 },
|
|
325
|
+
modules: {
|
|
326
|
+
git: { status: 'in-progress', xp_earned: 30 },
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const registry = { modules: [] };
|
|
330
|
+
const result = formatDashboard(progress, registry);
|
|
331
|
+
expect(result).toContain('◐');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should include dashboard border/frame', () => {
|
|
335
|
+
const progress = { user: { xp: 0 }, modules: {} };
|
|
336
|
+
const registry = { modules: [] };
|
|
337
|
+
const result = formatDashboard(progress, registry);
|
|
338
|
+
expect(result).toContain('ClaudeTeach Dashboard');
|
|
339
|
+
expect(result).toContain('╔');
|
|
340
|
+
expect(result).toContain('╚');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
/* ================================================================== */
|
|
345
|
+
/* suggestNext(progress, registry) */
|
|
346
|
+
/* ================================================================== */
|
|
347
|
+
|
|
348
|
+
describe('suggestNext', () => {
|
|
349
|
+
it('should return a string', () => {
|
|
350
|
+
const progress = { user: { xp: 0 }, modules: {} };
|
|
351
|
+
const registry = { modules: [] };
|
|
352
|
+
const result = suggestNext(progress, registry);
|
|
353
|
+
expect(typeof result).toBe('string');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should include current belt and XP in output', () => {
|
|
357
|
+
const progress = { user: { xp: 200 }, modules: {} };
|
|
358
|
+
const registry = { modules: [] };
|
|
359
|
+
const result = suggestNext(progress, registry);
|
|
360
|
+
expect(result).toContain('Green');
|
|
361
|
+
expect(result).toContain('200');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should include next belt info when not maxed', () => {
|
|
365
|
+
const progress = { user: { xp: 200 }, modules: {} };
|
|
366
|
+
const registry = { modules: [] };
|
|
367
|
+
const result = suggestNext(progress, registry);
|
|
368
|
+
expect(result).toContain('Blue');
|
|
369
|
+
expect(result).toContain('400');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should suggest in-progress modules first', () => {
|
|
373
|
+
const progress = {
|
|
374
|
+
user: { xp: 50 },
|
|
375
|
+
modules: {
|
|
376
|
+
git: { status: 'in-progress', walkthrough_step: 3 },
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
const registry = {
|
|
380
|
+
modules: [
|
|
381
|
+
{ slug: 'git', title: 'Git Basics', difficulty: 'beginner' },
|
|
382
|
+
{ slug: 'hooks', title: 'Git Hooks', difficulty: 'intermediate' },
|
|
383
|
+
],
|
|
384
|
+
};
|
|
385
|
+
const result = suggestNext(progress, registry);
|
|
386
|
+
expect(result).toContain('Continue where you left off');
|
|
387
|
+
expect(result).toContain('git');
|
|
388
|
+
expect(result).toContain('step 3');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('should suggest uncompleted modules sorted by difficulty', () => {
|
|
392
|
+
const progress = { user: { xp: 0 }, modules: {} };
|
|
393
|
+
const registry = {
|
|
394
|
+
modules: [
|
|
395
|
+
{ slug: 'advanced-git', title: 'Advanced Git', difficulty: 'advanced', xp: { read: 30 } },
|
|
396
|
+
{ slug: 'git', title: 'Git Basics', difficulty: 'beginner', xp: { read: 10 } },
|
|
397
|
+
{ slug: 'hooks', title: 'Hooks', difficulty: 'intermediate', xp: { read: 20 } },
|
|
398
|
+
],
|
|
399
|
+
};
|
|
400
|
+
const result = suggestNext(progress, registry);
|
|
401
|
+
expect(result).toContain('Recommended next');
|
|
402
|
+
// Beginner should appear before advanced in the output
|
|
403
|
+
const gitPos = result.indexOf('git');
|
|
404
|
+
const advPos = result.indexOf('advanced-git');
|
|
405
|
+
expect(gitPos).toBeLessThan(advPos);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('should respect prerequisites — skip modules whose prereqs are not met', () => {
|
|
409
|
+
const progress = { user: { xp: 0 }, modules: {} };
|
|
410
|
+
const registry = {
|
|
411
|
+
modules: [
|
|
412
|
+
{ slug: 'git', title: 'Git Basics', difficulty: 'beginner', prerequisites: [] },
|
|
413
|
+
{ slug: 'advanced-git', title: 'Advanced Git', difficulty: 'advanced', prerequisites: ['git'] },
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
const result = suggestNext(progress, registry);
|
|
417
|
+
expect(result).toContain('git');
|
|
418
|
+
// advanced-git should NOT appear since prerequisite 'git' is not completed
|
|
419
|
+
expect(result).not.toContain('advanced-git');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('should include modules whose prerequisites are all completed', () => {
|
|
423
|
+
const progress = {
|
|
424
|
+
user: { xp: 50 },
|
|
425
|
+
modules: {
|
|
426
|
+
git: { status: 'completed', xp_earned: 50 },
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
const registry = {
|
|
430
|
+
modules: [
|
|
431
|
+
{ slug: 'git', title: 'Git Basics', difficulty: 'beginner', prerequisites: [] },
|
|
432
|
+
{ slug: 'advanced-git', title: 'Advanced Git', difficulty: 'advanced', prerequisites: ['git'] },
|
|
433
|
+
],
|
|
434
|
+
};
|
|
435
|
+
const result = suggestNext(progress, registry);
|
|
436
|
+
expect(result).toContain('advanced-git');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should handle empty progress with empty registry', () => {
|
|
440
|
+
const progress = { user: { xp: 0 }, modules: {} };
|
|
441
|
+
const registry = { modules: [] };
|
|
442
|
+
const result = suggestNext(progress, registry);
|
|
443
|
+
expect(typeof result).toBe('string');
|
|
444
|
+
expect(result).toContain('White');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should handle missing user in progress', () => {
|
|
448
|
+
const progress = { modules: {} };
|
|
449
|
+
const registry = { modules: [] };
|
|
450
|
+
const result = suggestNext(progress, registry);
|
|
451
|
+
expect(typeof result).toBe('string');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should not suggest completed modules', () => {
|
|
455
|
+
const progress = {
|
|
456
|
+
user: { xp: 100 },
|
|
457
|
+
modules: {
|
|
458
|
+
git: { status: 'completed', xp_earned: 50 },
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
const registry = {
|
|
462
|
+
modules: [
|
|
463
|
+
{ slug: 'git', title: 'Git Basics', difficulty: 'beginner' },
|
|
464
|
+
{ slug: 'hooks', title: 'Git Hooks', difficulty: 'intermediate' },
|
|
465
|
+
],
|
|
466
|
+
};
|
|
467
|
+
const result = suggestNext(progress, registry);
|
|
468
|
+
// The "Recommended next" section should only contain hooks, not git
|
|
469
|
+
const recommendedIdx = result.indexOf('Recommended next');
|
|
470
|
+
if (recommendedIdx !== -1) {
|
|
471
|
+
const afterRecommended = result.slice(recommendedIdx);
|
|
472
|
+
expect(afterRecommended).toContain('hooks');
|
|
473
|
+
// git only appears in the belt/xp header, not in recommendations
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('should show at most 3 suggestions', () => {
|
|
478
|
+
const progress = { user: { xp: 0 }, modules: {} };
|
|
479
|
+
const registry = {
|
|
480
|
+
modules: [
|
|
481
|
+
{ slug: 'mod1', title: 'Mod 1', difficulty: 'beginner' },
|
|
482
|
+
{ slug: 'mod2', title: 'Mod 2', difficulty: 'beginner' },
|
|
483
|
+
{ slug: 'mod3', title: 'Mod 3', difficulty: 'beginner' },
|
|
484
|
+
{ slug: 'mod4', title: 'Mod 4', difficulty: 'beginner' },
|
|
485
|
+
{ slug: 'mod5', title: 'Mod 5', difficulty: 'beginner' },
|
|
486
|
+
],
|
|
487
|
+
};
|
|
488
|
+
const result = suggestNext(progress, registry);
|
|
489
|
+
const recommendedIdx = result.indexOf('Recommended next');
|
|
490
|
+
const afterRecommended = result.slice(recommendedIdx);
|
|
491
|
+
expect(afterRecommended).toContain('mod1');
|
|
492
|
+
expect(afterRecommended).toContain('mod2');
|
|
493
|
+
expect(afterRecommended).toContain('mod3');
|
|
494
|
+
expect(afterRecommended).not.toContain('mod4');
|
|
495
|
+
expect(afterRecommended).not.toContain('mod5');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('should show XP info for suggested modules that have xp data', () => {
|
|
499
|
+
const progress = { user: { xp: 0 }, modules: {} };
|
|
500
|
+
const registry = {
|
|
501
|
+
modules: [
|
|
502
|
+
{ slug: 'git', title: 'Git Basics', difficulty: 'beginner', xp: { read: 10, walkthrough: 20 } },
|
|
503
|
+
],
|
|
504
|
+
};
|
|
505
|
+
const result = suggestNext(progress, registry);
|
|
506
|
+
// Total XP = 10 + 20 = 30
|
|
507
|
+
expect(result).toContain('30 XP');
|
|
508
|
+
});
|
|
509
|
+
});
|