@vue-skuilder/db 0.1.17 → 0.1.18
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/dist/{userDB-DNa0XPtn.d.ts → classroomDB-BgfrVb8d.d.ts} +357 -103
- package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CTOenngH.d.cts} +358 -104
- package/dist/core/index.d.cts +230 -0
- package/dist/core/index.d.ts +161 -23
- package/dist/core/index.js +1964 -154
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +1925 -121
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-CZxC9GtB.d.ts} +1 -1
- package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D6PoCwS6.d.cts} +1 -1
- package/dist/impl/couch/{index.d.mts → index.d.cts} +46 -5
- package/dist/impl/couch/index.d.ts +44 -3
- package/dist/impl/couch/index.js +1971 -171
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +1933 -134
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/{index.d.mts → index.d.cts} +5 -6
- package/dist/impl/static/index.d.ts +2 -3
- package/dist/impl/static/index.js +1614 -119
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +1585 -92
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Bmll7Xse.d.mts → index-D-Fa4Smt.d.cts} +1 -1
- package/dist/{index.d.mts → index.d.cts} +97 -13
- package/dist/index.d.ts +90 -6
- package/dist/index.js +2085 -153
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2031 -106
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -3
- package/dist/{types-Dbp5DaRR.d.mts → types-CzPDLAK6.d.cts} +1 -1
- package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
- package/dist/util/packer/index.js.map +1 -1
- package/dist/util/packer/index.mjs.map +1 -1
- package/docs/brainstorm-navigation-paradigm.md +369 -0
- package/docs/navigators-architecture.md +265 -0
- package/docs/todo-evolutionary-orchestration.md +310 -0
- package/docs/todo-nominal-tag-types.md +121 -0
- package/docs/todo-pipeline-optimization.md +117 -0
- package/docs/todo-strategy-authoring.md +401 -0
- package/docs/todo-strategy-state-storage.md +278 -0
- package/eslint.config.mjs +1 -1
- package/package.json +9 -4
- package/src/core/interfaces/contentSource.ts +88 -4
- package/src/core/interfaces/navigationStrategyManager.ts +0 -5
- package/src/core/navigators/CompositeGenerator.ts +268 -0
- package/src/core/navigators/Pipeline.ts +205 -0
- package/src/core/navigators/PipelineAssembler.ts +194 -0
- package/src/core/navigators/elo.ts +104 -15
- package/src/core/navigators/filters/eloDistance.ts +132 -0
- package/src/core/navigators/filters/index.ts +6 -0
- package/src/core/navigators/filters/types.ts +115 -0
- package/src/core/navigators/generators/index.ts +2 -0
- package/src/core/navigators/generators/types.ts +107 -0
- package/src/core/navigators/hardcodedOrder.ts +111 -12
- package/src/core/navigators/hierarchyDefinition.ts +266 -0
- package/src/core/navigators/index.ts +345 -3
- package/src/core/navigators/interferenceMitigator.ts +367 -0
- package/src/core/navigators/relativePriority.ts +267 -0
- package/src/core/navigators/srs.ts +195 -0
- package/src/impl/couch/classroomDB.ts +51 -0
- package/src/impl/couch/courseDB.ts +117 -39
- package/src/impl/static/courseDB.ts +0 -4
- package/src/study/SessionController.ts +149 -1
- package/src/study/TagFilteredContentSource.ts +255 -0
- package/src/study/index.ts +1 -0
- package/src/util/dataDirectory.test.ts +51 -22
- package/src/util/logger.ts +0 -1
- package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
- package/tests/core/navigators/Pipeline.test.ts +405 -0
- package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
- package/tests/core/navigators/SRSNavigator.test.ts +344 -0
- package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
- package/tests/core/navigators/navigators.test.ts +710 -0
- package/tsconfig.json +1 -1
- package/vitest.config.ts +29 -0
- package/dist/core/index.d.mts +0 -92
- /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
- /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
- /package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-6ettoclI.d.cts} +0 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ContentNavigator, WeightedCard, getCardOrigin } from '../../../src/core/navigators/index';
|
|
3
|
+
import { StudySessionNewItem, StudySessionReviewItem } from '../../../src/core';
|
|
4
|
+
import { ScheduledCard } from '../../../src/core/types/user';
|
|
5
|
+
|
|
6
|
+
// Mock implementation of ContentNavigator for testing the default getWeightedCards
|
|
7
|
+
class MockNavigator extends ContentNavigator {
|
|
8
|
+
private mockNewCards: StudySessionNewItem[] = [];
|
|
9
|
+
private mockReviews: (StudySessionReviewItem & ScheduledCard)[] = [];
|
|
10
|
+
|
|
11
|
+
setMockNewCards(cards: StudySessionNewItem[]) {
|
|
12
|
+
this.mockNewCards = cards;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
setMockReviews(reviews: (StudySessionReviewItem & ScheduledCard)[]) {
|
|
16
|
+
this.mockReviews = reviews;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async getNewCards(n?: number): Promise<StudySessionNewItem[]> {
|
|
20
|
+
return this.mockNewCards.slice(0, n);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
24
|
+
return this.mockReviews;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('WeightedCard', () => {
|
|
29
|
+
it('should have correct structure with provenance', () => {
|
|
30
|
+
const card: WeightedCard = {
|
|
31
|
+
cardId: 'card-1',
|
|
32
|
+
courseId: 'course-1',
|
|
33
|
+
score: 0.8,
|
|
34
|
+
provenance: [
|
|
35
|
+
{
|
|
36
|
+
strategy: 'test',
|
|
37
|
+
strategyName: 'Test Strategy',
|
|
38
|
+
strategyId: 'TEST_STRATEGY',
|
|
39
|
+
action: 'generated',
|
|
40
|
+
score: 0.8,
|
|
41
|
+
reason: 'Test card, new',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
expect(card.cardId).toBe('card-1');
|
|
47
|
+
expect(card.courseId).toBe('course-1');
|
|
48
|
+
expect(card.score).toBe(0.8);
|
|
49
|
+
expect(card.provenance).toHaveLength(1);
|
|
50
|
+
expect(card.provenance[0].strategy).toBe('test');
|
|
51
|
+
expect(card.provenance[0].strategyName).toBe('Test Strategy');
|
|
52
|
+
expect(card.provenance[0].strategyId).toBe('TEST_STRATEGY');
|
|
53
|
+
expect(card.provenance[0].reason).toBe('Test card, new');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should support getCardOrigin helper for all origin types', () => {
|
|
57
|
+
const origins: Array<'new' | 'review' | 'failed'> = ['new', 'review', 'failed'];
|
|
58
|
+
|
|
59
|
+
origins.forEach((origin) => {
|
|
60
|
+
const card: WeightedCard = {
|
|
61
|
+
cardId: 'card-1',
|
|
62
|
+
courseId: 'course-1',
|
|
63
|
+
score: 1.0,
|
|
64
|
+
provenance: [
|
|
65
|
+
{
|
|
66
|
+
strategy: 'test',
|
|
67
|
+
strategyName: 'Test Strategy',
|
|
68
|
+
strategyId: 'TEST_STRATEGY',
|
|
69
|
+
action: 'generated',
|
|
70
|
+
score: 1.0,
|
|
71
|
+
reason: `Test card, ${origin}`,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
expect(getCardOrigin(card)).toBe(origin);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('ContentNavigator.getWeightedCards', () => {
|
|
81
|
+
let navigator: MockNavigator;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
navigator = new MockNavigator();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should return empty array when no cards available', async () => {
|
|
88
|
+
navigator.setMockNewCards([]);
|
|
89
|
+
navigator.setMockReviews([]);
|
|
90
|
+
|
|
91
|
+
const result = await navigator.getWeightedCards(10);
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should assign score=1.0 to all cards by default', async () => {
|
|
97
|
+
navigator.setMockNewCards([
|
|
98
|
+
{
|
|
99
|
+
cardID: 'card-1',
|
|
100
|
+
courseID: 'course-1',
|
|
101
|
+
contentSourceType: 'course',
|
|
102
|
+
contentSourceID: 'course-1',
|
|
103
|
+
status: 'new',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
cardID: 'card-2',
|
|
107
|
+
courseID: 'course-1',
|
|
108
|
+
contentSourceType: 'course',
|
|
109
|
+
contentSourceID: 'course-1',
|
|
110
|
+
status: 'new',
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const result = await navigator.getWeightedCards(10);
|
|
115
|
+
|
|
116
|
+
expect(result).toHaveLength(2);
|
|
117
|
+
expect(result[0].score).toBe(1.0);
|
|
118
|
+
expect(result[1].score).toBe(1.0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should mark new cards with source="new"', async () => {
|
|
122
|
+
navigator.setMockNewCards([
|
|
123
|
+
{
|
|
124
|
+
cardID: 'card-1',
|
|
125
|
+
courseID: 'course-1',
|
|
126
|
+
contentSourceType: 'course',
|
|
127
|
+
contentSourceID: 'course-1',
|
|
128
|
+
status: 'new',
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
const result = await navigator.getWeightedCards(10);
|
|
133
|
+
|
|
134
|
+
expect(getCardOrigin(result[0])).toBe('new');
|
|
135
|
+
expect(result[0].provenance[0].reason).toContain('new');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should mark reviews with origin="review"', async () => {
|
|
139
|
+
navigator.setMockReviews([
|
|
140
|
+
{
|
|
141
|
+
cardID: 'review-1',
|
|
142
|
+
courseID: 'course-1',
|
|
143
|
+
contentSourceType: 'course',
|
|
144
|
+
contentSourceID: 'course-1',
|
|
145
|
+
status: 'review',
|
|
146
|
+
reviewID: 'review-id-1',
|
|
147
|
+
_id: 'scheduled-1',
|
|
148
|
+
cardId: 'review-1',
|
|
149
|
+
courseId: 'course-1',
|
|
150
|
+
scheduledFor: 'course',
|
|
151
|
+
schedulingAgentId: 'agent-1',
|
|
152
|
+
reviewTime: new Date(),
|
|
153
|
+
scheduledAt: new Date(),
|
|
154
|
+
} as unknown as StudySessionReviewItem & ScheduledCard,
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const result = await navigator.getWeightedCards(10);
|
|
158
|
+
|
|
159
|
+
expect(getCardOrigin(result[0])).toBe('review');
|
|
160
|
+
expect(result[0].provenance[0].reason).toContain('review');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should respect limit parameter', async () => {
|
|
164
|
+
navigator.setMockNewCards([
|
|
165
|
+
{
|
|
166
|
+
cardID: 'card-1',
|
|
167
|
+
courseID: 'course-1',
|
|
168
|
+
contentSourceType: 'course',
|
|
169
|
+
contentSourceID: 'course-1',
|
|
170
|
+
status: 'new',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
cardID: 'card-2',
|
|
174
|
+
courseID: 'course-1',
|
|
175
|
+
contentSourceType: 'course',
|
|
176
|
+
contentSourceID: 'course-1',
|
|
177
|
+
status: 'new',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
cardID: 'card-3',
|
|
181
|
+
courseID: 'course-1',
|
|
182
|
+
contentSourceType: 'course',
|
|
183
|
+
contentSourceID: 'course-1',
|
|
184
|
+
status: 'new',
|
|
185
|
+
},
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
const result = await navigator.getWeightedCards(2);
|
|
189
|
+
|
|
190
|
+
expect(result).toHaveLength(2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should combine new cards and reviews', async () => {
|
|
194
|
+
navigator.setMockNewCards([
|
|
195
|
+
{
|
|
196
|
+
cardID: 'new-1',
|
|
197
|
+
courseID: 'course-1',
|
|
198
|
+
contentSourceType: 'course',
|
|
199
|
+
contentSourceID: 'course-1',
|
|
200
|
+
status: 'new',
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
navigator.setMockReviews([
|
|
204
|
+
{
|
|
205
|
+
cardID: 'review-1',
|
|
206
|
+
courseID: 'course-1',
|
|
207
|
+
contentSourceType: 'course',
|
|
208
|
+
contentSourceID: 'course-1',
|
|
209
|
+
status: 'review',
|
|
210
|
+
reviewID: 'review-id-1',
|
|
211
|
+
_id: 'scheduled-1',
|
|
212
|
+
cardId: 'review-1',
|
|
213
|
+
courseId: 'course-1',
|
|
214
|
+
scheduledFor: 'course',
|
|
215
|
+
schedulingAgentId: 'agent-1',
|
|
216
|
+
reviewTime: new Date(),
|
|
217
|
+
scheduledAt: new Date(),
|
|
218
|
+
} as unknown as StudySessionReviewItem & ScheduledCard,
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
const result = await navigator.getWeightedCards(10);
|
|
222
|
+
|
|
223
|
+
expect(result).toHaveLength(2);
|
|
224
|
+
const origins = result.map((c) => getCardOrigin(c));
|
|
225
|
+
expect(origins).toContain('new');
|
|
226
|
+
expect(origins).toContain('review');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should correctly map cardID to cardId and courseID to courseId', async () => {
|
|
230
|
+
navigator.setMockNewCards([
|
|
231
|
+
{
|
|
232
|
+
cardID: 'CARD-123',
|
|
233
|
+
courseID: 'COURSE-456',
|
|
234
|
+
contentSourceType: 'course',
|
|
235
|
+
contentSourceID: 'COURSE-456',
|
|
236
|
+
status: 'new',
|
|
237
|
+
},
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const result = await navigator.getWeightedCards(10);
|
|
241
|
+
|
|
242
|
+
expect(result[0].cardId).toBe('CARD-123');
|
|
243
|
+
expect(result[0].courseId).toBe('COURSE-456');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('ELO scoring formula', () => {
|
|
248
|
+
// These tests verify the scoring formula: max(0, 1 - distance / 500)
|
|
249
|
+
// Note: We test the formula logic, not the full ELONavigator (which requires mocking DB)
|
|
250
|
+
|
|
251
|
+
function calculateEloScore(userElo: number, cardElo: number): number {
|
|
252
|
+
const distance = Math.abs(cardElo - userElo);
|
|
253
|
+
return Math.max(0, 1 - distance / 500);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
it('should return 1.0 when ELOs match exactly', () => {
|
|
257
|
+
expect(calculateEloScore(1000, 1000)).toBe(1.0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should return 0.5 when distance is 250', () => {
|
|
261
|
+
expect(calculateEloScore(1000, 1250)).toBe(0.5);
|
|
262
|
+
expect(calculateEloScore(1000, 750)).toBe(0.5);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should return 0 when distance is 500 or more', () => {
|
|
266
|
+
expect(calculateEloScore(1000, 1500)).toBe(0);
|
|
267
|
+
expect(calculateEloScore(1000, 500)).toBe(0);
|
|
268
|
+
expect(calculateEloScore(1000, 2000)).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should return intermediate values for intermediate distances', () => {
|
|
272
|
+
expect(calculateEloScore(1000, 1100)).toBeCloseTo(0.8);
|
|
273
|
+
expect(calculateEloScore(1000, 900)).toBeCloseTo(0.8);
|
|
274
|
+
expect(calculateEloScore(1000, 1200)).toBeCloseTo(0.6);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should never return negative values', () => {
|
|
278
|
+
expect(calculateEloScore(0, 1000)).toBe(0);
|
|
279
|
+
expect(calculateEloScore(1000, 0)).toBe(0);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('HierarchyDefinition mastery detection', () => {
|
|
284
|
+
// Test the mastery logic without full DB mocking
|
|
285
|
+
|
|
286
|
+
interface MockTagElo {
|
|
287
|
+
score: number;
|
|
288
|
+
count: number;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
interface MasteryThreshold {
|
|
292
|
+
minElo?: number;
|
|
293
|
+
minCount?: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isTagMastered(
|
|
297
|
+
tagElo: MockTagElo | undefined,
|
|
298
|
+
threshold: MasteryThreshold | undefined,
|
|
299
|
+
userGlobalElo: number
|
|
300
|
+
): boolean {
|
|
301
|
+
if (!tagElo) return false;
|
|
302
|
+
|
|
303
|
+
const minCount = threshold?.minCount ?? 3;
|
|
304
|
+
if (tagElo.count < minCount) return false;
|
|
305
|
+
|
|
306
|
+
if (threshold?.minElo !== undefined) {
|
|
307
|
+
return tagElo.score >= threshold.minElo;
|
|
308
|
+
} else {
|
|
309
|
+
// Default: user ELO for tag > global user ELO
|
|
310
|
+
return tagElo.score >= userGlobalElo;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
it('should return false when tag has no ELO data', () => {
|
|
315
|
+
expect(isTagMastered(undefined, {}, 1000)).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should return false when count is below threshold', () => {
|
|
319
|
+
expect(isTagMastered({ score: 1200, count: 2 }, { minCount: 3 }, 1000)).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should return true when count meets threshold and ELO exceeds minElo', () => {
|
|
323
|
+
expect(isTagMastered({ score: 1100, count: 5 }, { minElo: 1000, minCount: 3 }, 900)).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should return false when ELO is below minElo threshold', () => {
|
|
327
|
+
expect(isTagMastered({ score: 900, count: 5 }, { minElo: 1000, minCount: 3 }, 800)).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should compare to global ELO when no minElo specified', () => {
|
|
331
|
+
// Tag ELO above global = mastered
|
|
332
|
+
expect(isTagMastered({ score: 1100, count: 5 }, {}, 1000)).toBe(true);
|
|
333
|
+
// Tag ELO below global = not mastered
|
|
334
|
+
expect(isTagMastered({ score: 900, count: 5 }, {}, 1000)).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should use default minCount of 3', () => {
|
|
338
|
+
expect(isTagMastered({ score: 1100, count: 3 }, {}, 1000)).toBe(true);
|
|
339
|
+
expect(isTagMastered({ score: 1100, count: 2 }, {}, 1000)).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('HierarchyDefinition unlocking logic', () => {
|
|
344
|
+
interface TagPrerequisite {
|
|
345
|
+
requires: string[];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function getUnlockedTags(
|
|
349
|
+
prerequisites: { [tagId: string]: TagPrerequisite },
|
|
350
|
+
masteredTags: Set<string>
|
|
351
|
+
): Set<string> {
|
|
352
|
+
const unlocked = new Set<string>();
|
|
353
|
+
|
|
354
|
+
for (const [tagId, prereq] of Object.entries(prerequisites)) {
|
|
355
|
+
const allPrereqsMet = prereq.requires.every((req) => masteredTags.has(req));
|
|
356
|
+
if (allPrereqsMet) {
|
|
357
|
+
unlocked.add(tagId);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return unlocked;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
it('should unlock tag when all prerequisites are mastered', () => {
|
|
365
|
+
const prerequisites = {
|
|
366
|
+
'tag-b': { requires: ['tag-a'] },
|
|
367
|
+
};
|
|
368
|
+
const mastered = new Set(['tag-a']);
|
|
369
|
+
|
|
370
|
+
const unlocked = getUnlockedTags(prerequisites, mastered);
|
|
371
|
+
|
|
372
|
+
expect(unlocked.has('tag-b')).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should not unlock tag when some prerequisites are missing', () => {
|
|
376
|
+
const prerequisites = {
|
|
377
|
+
'tag-c': { requires: ['tag-a', 'tag-b'] },
|
|
378
|
+
};
|
|
379
|
+
const mastered = new Set(['tag-a']); // missing tag-b
|
|
380
|
+
|
|
381
|
+
const unlocked = getUnlockedTags(prerequisites, mastered);
|
|
382
|
+
|
|
383
|
+
expect(unlocked.has('tag-c')).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should unlock tag when it has empty prerequisites', () => {
|
|
387
|
+
const prerequisites = {
|
|
388
|
+
'tag-root': { requires: [] },
|
|
389
|
+
};
|
|
390
|
+
const mastered = new Set<string>();
|
|
391
|
+
|
|
392
|
+
const unlocked = getUnlockedTags(prerequisites, mastered);
|
|
393
|
+
|
|
394
|
+
expect(unlocked.has('tag-root')).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should handle chain of prerequisites', () => {
|
|
398
|
+
const prerequisites = {
|
|
399
|
+
'tag-b': { requires: ['tag-a'] },
|
|
400
|
+
'tag-c': { requires: ['tag-b'] },
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Only tag-a mastered: tag-b unlocks, tag-c does not
|
|
404
|
+
const mastered1 = new Set(['tag-a']);
|
|
405
|
+
const unlocked1 = getUnlockedTags(prerequisites, mastered1);
|
|
406
|
+
expect(unlocked1.has('tag-b')).toBe(true);
|
|
407
|
+
expect(unlocked1.has('tag-c')).toBe(false);
|
|
408
|
+
|
|
409
|
+
// tag-a and tag-b mastered: both tag-b and tag-c unlock
|
|
410
|
+
const mastered2 = new Set(['tag-a', 'tag-b']);
|
|
411
|
+
const unlocked2 = getUnlockedTags(prerequisites, mastered2);
|
|
412
|
+
expect(unlocked2.has('tag-b')).toBe(true);
|
|
413
|
+
expect(unlocked2.has('tag-c')).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should handle multiple prerequisites', () => {
|
|
417
|
+
const prerequisites = {
|
|
418
|
+
'cvc-words': { requires: ['letter-s', 'letter-a', 'letter-t'] },
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Missing one prerequisite
|
|
422
|
+
const mastered1 = new Set(['letter-s', 'letter-a']);
|
|
423
|
+
expect(getUnlockedTags(prerequisites, mastered1).has('cvc-words')).toBe(false);
|
|
424
|
+
|
|
425
|
+
// All prerequisites met
|
|
426
|
+
const mastered2 = new Set(['letter-s', 'letter-a', 'letter-t']);
|
|
427
|
+
expect(getUnlockedTags(prerequisites, mastered2).has('cvc-words')).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe('HierarchyDefinition card unlocking', () => {
|
|
432
|
+
function isCardUnlocked(
|
|
433
|
+
cardTags: string[],
|
|
434
|
+
unlockedTags: Set<string>,
|
|
435
|
+
hasPrerequisites: (tag: string) => boolean
|
|
436
|
+
): boolean {
|
|
437
|
+
return cardTags.every((tag) => unlockedTags.has(tag) || !hasPrerequisites(tag));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
it('should unlock card when all its tags are unlocked', () => {
|
|
441
|
+
const cardTags = ['tag-a', 'tag-b'];
|
|
442
|
+
const unlockedTags = new Set(['tag-a', 'tag-b']);
|
|
443
|
+
const hasPrereqs = () => true;
|
|
444
|
+
|
|
445
|
+
expect(isCardUnlocked(cardTags, unlockedTags, hasPrereqs)).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('should lock card when any tag is locked', () => {
|
|
449
|
+
const cardTags = ['tag-a', 'tag-b'];
|
|
450
|
+
const unlockedTags = new Set(['tag-a']); // tag-b not unlocked
|
|
451
|
+
const hasPrereqs = () => true;
|
|
452
|
+
|
|
453
|
+
expect(isCardUnlocked(cardTags, unlockedTags, hasPrereqs)).toBe(false);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('should unlock card when tag has no prerequisites defined', () => {
|
|
457
|
+
const cardTags = ['tag-without-prereqs'];
|
|
458
|
+
const unlockedTags = new Set<string>(); // nothing explicitly unlocked
|
|
459
|
+
const hasPrereqs = (tag: string) => tag !== 'tag-without-prereqs';
|
|
460
|
+
|
|
461
|
+
expect(isCardUnlocked(cardTags, unlockedTags, hasPrereqs)).toBe(true);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should handle mixed tags (some with prereqs, some without)', () => {
|
|
465
|
+
const cardTags = ['defined-tag', 'root-tag'];
|
|
466
|
+
const unlockedTags = new Set(['defined-tag']);
|
|
467
|
+
const hasPrereqs = (tag: string) => tag === 'defined-tag';
|
|
468
|
+
|
|
469
|
+
// defined-tag is unlocked, root-tag has no prereqs = card unlocked
|
|
470
|
+
expect(isCardUnlocked(cardTags, unlockedTags, hasPrereqs)).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('should lock card when defined tag is not unlocked', () => {
|
|
474
|
+
const cardTags = ['defined-tag', 'root-tag'];
|
|
475
|
+
const unlockedTags = new Set<string>(); // defined-tag not unlocked
|
|
476
|
+
const hasPrereqs = (tag: string) => tag === 'defined-tag';
|
|
477
|
+
|
|
478
|
+
expect(isCardUnlocked(cardTags, unlockedTags, hasPrereqs)).toBe(false);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe('HierarchyDefinition score multiplier behavior', () => {
|
|
483
|
+
/**
|
|
484
|
+
* Tests the filter API convention: filters use score multipliers, not hard filtering.
|
|
485
|
+
* Locked cards receive score: 0 instead of being removed from results.
|
|
486
|
+
*
|
|
487
|
+
* This is critical for:
|
|
488
|
+
* - Order-independent filter composition (multiplication is commutative)
|
|
489
|
+
* - Future provenance tracking (all candidates visible, including score: 0)
|
|
490
|
+
*/
|
|
491
|
+
function applyHierarchyGating(
|
|
492
|
+
cards: Array<{ cardId: string; score: number }>,
|
|
493
|
+
unlockedCards: Set<string>
|
|
494
|
+
): Array<{ cardId: string; score: number }> {
|
|
495
|
+
return cards.map((card) => ({
|
|
496
|
+
...card,
|
|
497
|
+
score: unlockedCards.has(card.cardId) ? card.score : 0,
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
it('should return score: 0 for locked cards instead of filtering', () => {
|
|
502
|
+
const cards = [
|
|
503
|
+
{ cardId: 'unlocked-card', score: 0.8 },
|
|
504
|
+
{ cardId: 'locked-card', score: 0.9 },
|
|
505
|
+
];
|
|
506
|
+
const unlockedCards = new Set(['unlocked-card']);
|
|
507
|
+
|
|
508
|
+
const result = applyHierarchyGating(cards, unlockedCards);
|
|
509
|
+
|
|
510
|
+
expect(result).toHaveLength(2);
|
|
511
|
+
expect(result.find((c) => c.cardId === 'unlocked-card')!.score).toBe(0.8);
|
|
512
|
+
expect(result.find((c) => c.cardId === 'locked-card')!.score).toBe(0);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should preserve score for unlocked cards', () => {
|
|
516
|
+
const cards = [
|
|
517
|
+
{ cardId: 'card-1', score: 0.9 },
|
|
518
|
+
{ cardId: 'card-2', score: 0.7 },
|
|
519
|
+
];
|
|
520
|
+
const unlockedCards = new Set(['card-1', 'card-2']);
|
|
521
|
+
|
|
522
|
+
const result = applyHierarchyGating(cards, unlockedCards);
|
|
523
|
+
|
|
524
|
+
expect(result).toHaveLength(2);
|
|
525
|
+
expect(result[0].score).toBe(0.9);
|
|
526
|
+
expect(result[1].score).toBe(0.7);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('should set all cards to score: 0 when none are unlocked', () => {
|
|
530
|
+
const cards = [
|
|
531
|
+
{ cardId: 'card-1', score: 0.8 },
|
|
532
|
+
{ cardId: 'card-2', score: 0.6 },
|
|
533
|
+
];
|
|
534
|
+
const unlockedCards = new Set<string>();
|
|
535
|
+
|
|
536
|
+
const result = applyHierarchyGating(cards, unlockedCards);
|
|
537
|
+
|
|
538
|
+
expect(result).toHaveLength(2);
|
|
539
|
+
expect(result[0].score).toBe(0);
|
|
540
|
+
expect(result[1].score).toBe(0);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe('RelativePriority boost factor computation', () => {
|
|
545
|
+
// Test the boost factor formula: 1 + (priority - 0.5) * priorityInfluence
|
|
546
|
+
|
|
547
|
+
function computeBoostFactor(priority: number, priorityInfluence: number): number {
|
|
548
|
+
return 1 + (priority - 0.5) * priorityInfluence;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
it('should return 1.0 for neutral priority (0.5)', () => {
|
|
552
|
+
expect(computeBoostFactor(0.5, 0.5)).toBe(1.0);
|
|
553
|
+
expect(computeBoostFactor(0.5, 1.0)).toBe(1.0);
|
|
554
|
+
expect(computeBoostFactor(0.5, 0.0)).toBe(1.0);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('should boost high-priority content', () => {
|
|
558
|
+
// Priority 1.0 with influence 0.5 → boost of 1.25
|
|
559
|
+
expect(computeBoostFactor(1.0, 0.5)).toBeCloseTo(1.25);
|
|
560
|
+
// Priority 1.0 with influence 1.0 → boost of 1.5
|
|
561
|
+
expect(computeBoostFactor(1.0, 1.0)).toBeCloseTo(1.5);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('should reduce low-priority content', () => {
|
|
565
|
+
// Priority 0.0 with influence 0.5 → factor of 0.75
|
|
566
|
+
expect(computeBoostFactor(0.0, 0.5)).toBeCloseTo(0.75);
|
|
567
|
+
// Priority 0.0 with influence 1.0 → factor of 0.5
|
|
568
|
+
expect(computeBoostFactor(0.0, 1.0)).toBeCloseTo(0.5);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('should have no effect when influence is 0', () => {
|
|
572
|
+
expect(computeBoostFactor(1.0, 0.0)).toBe(1.0);
|
|
573
|
+
expect(computeBoostFactor(0.0, 0.0)).toBe(1.0);
|
|
574
|
+
expect(computeBoostFactor(0.75, 0.0)).toBe(1.0);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('should scale linearly with priority', () => {
|
|
578
|
+
const influence = 0.5;
|
|
579
|
+
// 0.75 priority (halfway between 0.5 and 1.0)
|
|
580
|
+
expect(computeBoostFactor(0.75, influence)).toBeCloseTo(1.125);
|
|
581
|
+
// 0.25 priority (halfway between 0.0 and 0.5)
|
|
582
|
+
expect(computeBoostFactor(0.25, influence)).toBeCloseTo(0.875);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe('RelativePriority tag priority combination', () => {
|
|
587
|
+
function computeCardPriority(
|
|
588
|
+
cardTags: string[],
|
|
589
|
+
tagPriorities: { [tagId: string]: number },
|
|
590
|
+
defaultPriority: number,
|
|
591
|
+
combineMode: 'max' | 'average' | 'min'
|
|
592
|
+
): number {
|
|
593
|
+
if (cardTags.length === 0) {
|
|
594
|
+
return defaultPriority;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const priorities = cardTags.map((tag) => tagPriorities[tag] ?? defaultPriority);
|
|
598
|
+
|
|
599
|
+
switch (combineMode) {
|
|
600
|
+
case 'max':
|
|
601
|
+
return Math.max(...priorities);
|
|
602
|
+
case 'min':
|
|
603
|
+
return Math.min(...priorities);
|
|
604
|
+
case 'average':
|
|
605
|
+
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
606
|
+
default:
|
|
607
|
+
return Math.max(...priorities);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const tagPriorities = {
|
|
612
|
+
'letter-s': 0.95,
|
|
613
|
+
'letter-t': 0.9,
|
|
614
|
+
'letter-x': 0.1,
|
|
615
|
+
'letter-z': 0.05,
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
it('should return default priority for cards with no tags', () => {
|
|
619
|
+
expect(computeCardPriority([], tagPriorities, 0.5, 'max')).toBe(0.5);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('should return tag priority for single-tag card', () => {
|
|
623
|
+
expect(computeCardPriority(['letter-s'], tagPriorities, 0.5, 'max')).toBe(0.95);
|
|
624
|
+
expect(computeCardPriority(['letter-x'], tagPriorities, 0.5, 'max')).toBe(0.1);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('should use default priority for unlisted tags', () => {
|
|
628
|
+
expect(computeCardPriority(['unknown-tag'], tagPriorities, 0.5, 'max')).toBe(0.5);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('should use max mode correctly', () => {
|
|
632
|
+
// Mixed high and low priority tags
|
|
633
|
+
expect(computeCardPriority(['letter-s', 'letter-x'], tagPriorities, 0.5, 'max')).toBe(0.95);
|
|
634
|
+
expect(computeCardPriority(['letter-z', 'letter-x'], tagPriorities, 0.5, 'max')).toBe(0.1);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('should use min mode correctly', () => {
|
|
638
|
+
expect(computeCardPriority(['letter-s', 'letter-x'], tagPriorities, 0.5, 'min')).toBe(0.1);
|
|
639
|
+
expect(computeCardPriority(['letter-s', 'letter-t'], tagPriorities, 0.5, 'min')).toBe(0.9);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('should use average mode correctly', () => {
|
|
643
|
+
// Average of 0.95 and 0.10 = 0.525
|
|
644
|
+
expect(
|
|
645
|
+
computeCardPriority(['letter-s', 'letter-x'], tagPriorities, 0.5, 'average')
|
|
646
|
+
).toBeCloseTo(0.525);
|
|
647
|
+
// Average of 0.95, 0.90, 0.10 = 0.65
|
|
648
|
+
expect(
|
|
649
|
+
computeCardPriority(['letter-s', 'letter-t', 'letter-x'], tagPriorities, 0.5, 'average')
|
|
650
|
+
).toBeCloseTo(0.65);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('should include default priority in average for mixed tags', () => {
|
|
654
|
+
// 'letter-s' = 0.95, 'unknown' = 0.5 (default), average = 0.725
|
|
655
|
+
expect(
|
|
656
|
+
computeCardPriority(['letter-s', 'unknown-tag'], tagPriorities, 0.5, 'average')
|
|
657
|
+
).toBeCloseTo(0.725);
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
describe('RelativePriority score adjustment', () => {
|
|
662
|
+
function adjustScore(delegateScore: number, priority: number, priorityInfluence: number): number {
|
|
663
|
+
const boostFactor = 1 + (priority - 0.5) * priorityInfluence;
|
|
664
|
+
// Clamp to [0, 1]
|
|
665
|
+
return Math.max(0, Math.min(1, delegateScore * boostFactor));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
it('should boost high-priority cards', () => {
|
|
669
|
+
// Delegate score 0.8, priority 1.0, influence 0.5 → 0.8 * 1.25 = 1.0 (clamped)
|
|
670
|
+
expect(adjustScore(0.8, 1.0, 0.5)).toBe(1.0);
|
|
671
|
+
// Delegate score 0.6, priority 1.0, influence 0.5 → 0.6 * 1.25 = 0.75
|
|
672
|
+
expect(adjustScore(0.6, 1.0, 0.5)).toBeCloseTo(0.75);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('should reduce low-priority cards', () => {
|
|
676
|
+
// Delegate score 0.8, priority 0.0, influence 0.5 → 0.8 * 0.75 = 0.6
|
|
677
|
+
expect(adjustScore(0.8, 0.0, 0.5)).toBeCloseTo(0.6);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('should leave neutral-priority cards unchanged', () => {
|
|
681
|
+
expect(adjustScore(0.8, 0.5, 0.5)).toBe(0.8);
|
|
682
|
+
expect(adjustScore(0.5, 0.5, 1.0)).toBe(0.5);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('should clamp scores to maximum of 1.0', () => {
|
|
686
|
+
// High delegate score with high priority should cap at 1.0
|
|
687
|
+
expect(adjustScore(0.9, 1.0, 1.0)).toBe(1.0);
|
|
688
|
+
expect(adjustScore(1.0, 0.8, 0.5)).toBe(1.0);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('should clamp scores to minimum of 0.0', () => {
|
|
692
|
+
// Low delegate score with low priority and high influence
|
|
693
|
+
// 0.3 * 0.5 = 0.15 (priority 0, influence 1.0)
|
|
694
|
+
expect(adjustScore(0.3, 0.0, 1.0)).toBeCloseTo(0.15);
|
|
695
|
+
// Edge case: should never go below 0
|
|
696
|
+
expect(adjustScore(0.1, 0.0, 1.0)).toBeGreaterThanOrEqual(0);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('should preserve ordering for cards with different priorities', () => {
|
|
700
|
+
const delegateScore = 0.7;
|
|
701
|
+
const influence = 0.5;
|
|
702
|
+
|
|
703
|
+
const highPriorityScore = adjustScore(delegateScore, 0.95, influence);
|
|
704
|
+
const mediumPriorityScore = adjustScore(delegateScore, 0.5, influence);
|
|
705
|
+
const lowPriorityScore = adjustScore(delegateScore, 0.1, influence);
|
|
706
|
+
|
|
707
|
+
expect(highPriorityScore).toBeGreaterThan(mediumPriorityScore);
|
|
708
|
+
expect(mediumPriorityScore).toBeGreaterThan(lowPriorityScore);
|
|
709
|
+
});
|
|
710
|
+
});
|