musora-content-services 2.94.8 → 2.95.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/CHANGELOG.md +16 -0
- package/CLAUDE.md +408 -0
- package/babel.config.cjs +10 -0
- package/jsdoc.json +2 -1
- package/package.json +2 -2
- package/src/constants/award-assets.js +35 -0
- package/src/filterBuilder.js +7 -2
- package/src/index.d.ts +26 -5
- package/src/index.js +26 -5
- package/src/services/awards/award-callbacks.js +126 -0
- package/src/services/awards/award-query.js +327 -0
- package/src/services/awards/internal/.indexignore +1 -0
- package/src/services/awards/internal/award-definitions.js +239 -0
- package/src/services/awards/internal/award-events.js +102 -0
- package/src/services/awards/internal/award-manager.js +162 -0
- package/src/services/awards/internal/certificate-builder.js +66 -0
- package/src/services/awards/internal/completion-data-generator.js +84 -0
- package/src/services/awards/internal/content-progress-observer.js +137 -0
- package/src/services/awards/internal/image-utils.js +62 -0
- package/src/services/awards/internal/message-generator.js +17 -0
- package/src/services/awards/internal/types.js +5 -0
- package/src/services/awards/types.d.ts +79 -0
- package/src/services/awards/types.js +101 -0
- package/src/services/config.js +24 -4
- package/src/services/content-org/learning-paths.ts +19 -15
- package/src/services/gamification/awards.ts +114 -83
- package/src/services/progress-events.js +58 -0
- package/src/services/progress-row/method-card.js +20 -5
- package/src/services/sanity.js +1 -1
- package/src/services/sync/fetch.ts +10 -2
- package/src/services/sync/manager.ts +6 -0
- package/src/services/sync/models/ContentProgress.ts +5 -6
- package/src/services/sync/models/UserAwardProgress.ts +55 -0
- package/src/services/sync/models/index.ts +1 -0
- package/src/services/sync/repositories/content-progress.ts +47 -25
- package/src/services/sync/repositories/index.ts +1 -0
- package/src/services/sync/repositories/practices.ts +16 -1
- package/src/services/sync/repositories/user-award-progress.ts +133 -0
- package/src/services/sync/repository-proxy.ts +6 -0
- package/src/services/sync/retry.ts +12 -11
- package/src/services/sync/schema/index.ts +18 -3
- package/src/services/sync/store/index.ts +53 -8
- package/src/services/sync/store/push-coalescer.ts +3 -3
- package/src/services/sync/store-configs.ts +7 -1
- package/src/services/userActivity.js +0 -1
- package/test/HttpClient.test.js +6 -6
- package/test/awards/award-alacarte-observer.test.js +196 -0
- package/test/awards/award-auto-refresh.test.js +83 -0
- package/test/awards/award-calculations.test.js +33 -0
- package/test/awards/award-certificate-display.test.js +328 -0
- package/test/awards/award-collection-edge-cases.test.js +210 -0
- package/test/awards/award-collection-filtering.test.js +285 -0
- package/test/awards/award-completion-flow.test.js +213 -0
- package/test/awards/award-exclusion-handling.test.js +273 -0
- package/test/awards/award-multi-lesson.test.js +241 -0
- package/test/awards/award-observer-integration.test.js +325 -0
- package/test/awards/award-query-messages.test.js +438 -0
- package/test/awards/award-user-collection.test.js +412 -0
- package/test/awards/duplicate-prevention.test.js +118 -0
- package/test/awards/helpers/completion-mock.js +54 -0
- package/test/awards/helpers/index.js +3 -0
- package/test/awards/helpers/mock-setup.js +69 -0
- package/test/awards/helpers/progress-emitter.js +39 -0
- package/test/awards/message-generator.test.js +162 -0
- package/test/initializeTests.js +6 -0
- package/test/mockData/award-definitions.js +171 -0
- package/test/sync/models/award-database-integration.test.js +519 -0
- package/tools/generate-index.cjs +9 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { awardManager } from '../../src/services/awards/internal/award-manager'
|
|
2
|
+
import { awardEvents } from '../../src/services/awards/internal/award-events'
|
|
3
|
+
import { mockAwardDefinitions, getAwardByContentId } from '../mockData/award-definitions'
|
|
4
|
+
import { globalConfig } from '../../src/services/config'
|
|
5
|
+
import { LocalStorageMock } from '../localStorageMock'
|
|
6
|
+
import { setupDefaultMocks, setupAwardEventListeners } from './helpers'
|
|
7
|
+
import { mockCompletionStates, mockAllCompleted, mockNoneCompleted } from './helpers/completion-mock'
|
|
8
|
+
|
|
9
|
+
jest.mock('../../src/services/sanity', () => ({
|
|
10
|
+
default: { fetch: jest.fn() },
|
|
11
|
+
fetchSanity: jest.fn()
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('../../src/services/railcontent', () => ({
|
|
15
|
+
...jest.requireActual('../../src/services/railcontent'),
|
|
16
|
+
fetchUserPermissionsData: jest.fn().mockResolvedValue({ permissions: [108, 91, 92], isAdmin: false })
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
jest.mock('../../src/services/sync/repository-proxy', () => {
|
|
20
|
+
const mockFns = {
|
|
21
|
+
contentProgress: {
|
|
22
|
+
getOneProgressByContentId: jest.fn(),
|
|
23
|
+
getSomeProgressByContentIds: jest.fn(),
|
|
24
|
+
queryOne: jest.fn(),
|
|
25
|
+
queryAll: jest.fn()
|
|
26
|
+
},
|
|
27
|
+
practices: {
|
|
28
|
+
sumPracticeMinutesForContent: jest.fn()
|
|
29
|
+
},
|
|
30
|
+
userAwardProgress: {
|
|
31
|
+
hasCompletedAward: jest.fn(),
|
|
32
|
+
recordAwardProgress: jest.fn(),
|
|
33
|
+
getByAwardId: jest.fn(),
|
|
34
|
+
completeAward: jest.fn()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { default: mockFns, ...mockFns }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
import sanityClient, { fetchSanity } from '../../src/services/sanity'
|
|
41
|
+
import db from '../../src/services/sync/repository-proxy'
|
|
42
|
+
import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
|
|
43
|
+
|
|
44
|
+
describe('Award Content Exclusion Handling - E2E Scenarios', () => {
|
|
45
|
+
let listeners
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
jest.clearAllMocks()
|
|
49
|
+
globalConfig.localStorage = new LocalStorageMock()
|
|
50
|
+
awardEvents.removeAllListeners()
|
|
51
|
+
|
|
52
|
+
sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
|
|
53
|
+
setupDefaultMocks(db, fetchSanity, { practiceMinutes: 150 })
|
|
54
|
+
|
|
55
|
+
db.userAwardProgress.completeAward = jest.fn().mockResolvedValue({ data: {}, status: 'synced' })
|
|
56
|
+
|
|
57
|
+
db.contentProgress.getSomeProgressByContentIds.mockResolvedValue({
|
|
58
|
+
data: [{ created_at: Math.floor(Date.now() / 1000) - 86400 * 7 }]
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await awardDefinitions.refresh()
|
|
62
|
+
|
|
63
|
+
listeners = setupAwardEventListeners(awardEvents)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
awardEvents.removeAllListeners()
|
|
68
|
+
awardDefinitions.clear()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('Scenario: Guided course with excluded intro video (416446 - 1 eligible lesson)', () => {
|
|
72
|
+
const testAward = getAwardByContentId(416446)
|
|
73
|
+
const courseId = 416446
|
|
74
|
+
|
|
75
|
+
test('completing the single eligible lesson grants award at 100%', async () => {
|
|
76
|
+
mockAllCompleted(db)
|
|
77
|
+
|
|
78
|
+
await awardManager.onContentCompleted(courseId)
|
|
79
|
+
|
|
80
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
81
|
+
expect.any(String),
|
|
82
|
+
100,
|
|
83
|
+
expect.objectContaining({
|
|
84
|
+
completedAt: expect.any(Number),
|
|
85
|
+
immediate: true
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('shows 0% progress when eligible lesson not completed', async () => {
|
|
92
|
+
mockNoneCompleted(db)
|
|
93
|
+
|
|
94
|
+
await awardManager.onContentCompleted(courseId)
|
|
95
|
+
|
|
96
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
97
|
+
testAward._id,
|
|
98
|
+
0,
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
progressData: expect.any(Object)
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('Scenario: Course with 4 eligible lessons (417049 - intro excluded)', () => {
|
|
107
|
+
const testAward = getAwardByContentId(417049)
|
|
108
|
+
const courseId = 417049
|
|
109
|
+
|
|
110
|
+
test('completing 1 of 4 lessons shows 25% progress', async () => {
|
|
111
|
+
mockCompletionStates(db, [417045])
|
|
112
|
+
|
|
113
|
+
await awardManager.onContentCompleted(courseId)
|
|
114
|
+
|
|
115
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
116
|
+
testAward._id,
|
|
117
|
+
25,
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
progressData: expect.any(Object)
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('completing 2 of 4 lessons shows 50% progress', async () => {
|
|
125
|
+
mockCompletionStates(db, [417045, 417046])
|
|
126
|
+
|
|
127
|
+
await awardManager.onContentCompleted(courseId)
|
|
128
|
+
|
|
129
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
130
|
+
testAward._id,
|
|
131
|
+
50,
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
progressData: expect.any(Object)
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('must complete all 4 eligible lessons to earn award', async () => {
|
|
139
|
+
mockCompletionStates(db, [417045, 417046, 417047, 417048])
|
|
140
|
+
|
|
141
|
+
await awardManager.onContentCompleted(courseId)
|
|
142
|
+
|
|
143
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
144
|
+
expect.any(String),
|
|
145
|
+
100,
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
completedAt: expect.any(Number),
|
|
148
|
+
immediate: true
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('Scenario: Large course with 23 eligible lessons (416464 - intro excluded)', () => {
|
|
156
|
+
const testAward = getAwardByContentId(416464)
|
|
157
|
+
const courseId = 416464
|
|
158
|
+
const eligibleLessons = [
|
|
159
|
+
416467, 416468, 416469, 416470, 416471, 416472, 416473,
|
|
160
|
+
416474, 416475, 416476, 416477, 416478, 416479, 416480, 416481,
|
|
161
|
+
416482, 416483, 416484, 416485, 416486, 416487, 416488, 416489
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
test('shows 0% progress when no eligible lessons completed', async () => {
|
|
165
|
+
mockNoneCompleted(db)
|
|
166
|
+
|
|
167
|
+
await awardManager.onContentCompleted(courseId)
|
|
168
|
+
|
|
169
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
170
|
+
testAward._id,
|
|
171
|
+
0,
|
|
172
|
+
expect.objectContaining({
|
|
173
|
+
progressData: expect.any(Object)
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('completing 12 of 23 lessons shows ~52% progress', async () => {
|
|
179
|
+
mockCompletionStates(db, eligibleLessons.slice(0, 12))
|
|
180
|
+
|
|
181
|
+
await awardManager.onContentCompleted(courseId)
|
|
182
|
+
|
|
183
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
184
|
+
testAward._id,
|
|
185
|
+
52,
|
|
186
|
+
expect.objectContaining({
|
|
187
|
+
progressData: expect.any(Object)
|
|
188
|
+
})
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('must complete all 23 eligible lessons to earn award', async () => {
|
|
193
|
+
mockCompletionStates(db, eligibleLessons)
|
|
194
|
+
|
|
195
|
+
await awardManager.onContentCompleted(courseId)
|
|
196
|
+
|
|
197
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
198
|
+
expect.any(String),
|
|
199
|
+
100,
|
|
200
|
+
expect.objectContaining({
|
|
201
|
+
completedAt: expect.any(Number),
|
|
202
|
+
immediate: true
|
|
203
|
+
})
|
|
204
|
+
)
|
|
205
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('Scenario: Course type without excluded content (417039 - all 3 lessons count)', () => {
|
|
210
|
+
const testAward = getAwardByContentId(417039)
|
|
211
|
+
const courseId = 417039
|
|
212
|
+
|
|
213
|
+
test('first lesson counts toward progress', async () => {
|
|
214
|
+
mockCompletionStates(db, [417035])
|
|
215
|
+
|
|
216
|
+
await awardManager.onContentCompleted(courseId)
|
|
217
|
+
|
|
218
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
219
|
+
testAward._id,
|
|
220
|
+
33,
|
|
221
|
+
expect.objectContaining({
|
|
222
|
+
progressData: expect.any(Object)
|
|
223
|
+
})
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('all 3 lessons must be completed to earn award', async () => {
|
|
228
|
+
mockAllCompleted(db)
|
|
229
|
+
|
|
230
|
+
await awardManager.onContentCompleted(courseId)
|
|
231
|
+
|
|
232
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
233
|
+
expect.any(String),
|
|
234
|
+
100,
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
completedAt: expect.any(Number),
|
|
237
|
+
immediate: true
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('Scenario: Learning path content type (417140)', () => {
|
|
244
|
+
const testAward = getAwardByContentId(417140)
|
|
245
|
+
const courseId = 417140
|
|
246
|
+
|
|
247
|
+
test('all child content counts toward progress', async () => {
|
|
248
|
+
mockCompletionStates(db, testAward.child_ids.slice(0, 11))
|
|
249
|
+
|
|
250
|
+
await awardManager.onContentCompleted(courseId)
|
|
251
|
+
|
|
252
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
253
|
+
testAward._id,
|
|
254
|
+
50,
|
|
255
|
+
expect.objectContaining({
|
|
256
|
+
progressData: expect.any(Object)
|
|
257
|
+
})
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('Scenario: Skill pack content type (418000)', () => {
|
|
263
|
+
const courseId = 418000
|
|
264
|
+
|
|
265
|
+
test('completing all lessons grants award', async () => {
|
|
266
|
+
mockAllCompleted(db)
|
|
267
|
+
|
|
268
|
+
await awardManager.onContentCompleted(courseId)
|
|
269
|
+
|
|
270
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { awardManager } from '../../src/services/awards/internal/award-manager'
|
|
2
|
+
import { awardEvents } from '../../src/services/awards/internal/award-events'
|
|
3
|
+
import { mockAwardDefinitions, getAwardByContentId } from '../mockData/award-definitions'
|
|
4
|
+
import { setupDefaultMocks, setupAwardEventListeners } from './helpers'
|
|
5
|
+
import { mockCompletionStates, mockAllCompleted, mockNoneCompleted } from './helpers/completion-mock'
|
|
6
|
+
|
|
7
|
+
jest.mock('../../src/services/sanity', () => ({
|
|
8
|
+
default: { fetch: jest.fn() },
|
|
9
|
+
fetchSanity: jest.fn()
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.mock('../../src/services/sync/repository-proxy', () => {
|
|
13
|
+
const mockFns = {
|
|
14
|
+
contentProgress: {
|
|
15
|
+
getOneProgressByContentId: jest.fn(),
|
|
16
|
+
getSomeProgressByContentIds: jest.fn(),
|
|
17
|
+
queryOne: jest.fn(),
|
|
18
|
+
queryAll: jest.fn()
|
|
19
|
+
},
|
|
20
|
+
practices: {
|
|
21
|
+
sumPracticeMinutesForContent: jest.fn()
|
|
22
|
+
},
|
|
23
|
+
userAwardProgress: {
|
|
24
|
+
hasCompletedAward: jest.fn(),
|
|
25
|
+
recordAwardProgress: jest.fn(),
|
|
26
|
+
getByAwardId: jest.fn()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { default: mockFns, ...mockFns }
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
import sanityClient, { fetchSanity } from '../../src/services/sanity'
|
|
33
|
+
import db from '../../src/services/sync/repository-proxy'
|
|
34
|
+
import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
|
|
35
|
+
|
|
36
|
+
describe('Award Progress Calculation', () => {
|
|
37
|
+
let listeners
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
jest.clearAllMocks()
|
|
41
|
+
awardEvents.removeAllListeners()
|
|
42
|
+
|
|
43
|
+
sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
|
|
44
|
+
setupDefaultMocks(db, fetchSanity, { practiceMinutes: 120 })
|
|
45
|
+
|
|
46
|
+
db.contentProgress.getSomeProgressByContentIds.mockResolvedValue({
|
|
47
|
+
data: [{ created_at: Math.floor(Date.now() / 1000) - 86400 * 5 }]
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
await awardDefinitions.refresh()
|
|
51
|
+
|
|
52
|
+
listeners = setupAwardEventListeners(awardEvents)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
awardEvents.removeAllListeners()
|
|
57
|
+
awardDefinitions.clear()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('Single lesson course (content_id: 416446, child_ids: [416448])', () => {
|
|
61
|
+
const award = getAwardByContentId(416446)
|
|
62
|
+
const childId = 416448
|
|
63
|
+
|
|
64
|
+
test('grants award at 100% when the single lesson is completed', async () => {
|
|
65
|
+
mockAllCompleted(db)
|
|
66
|
+
|
|
67
|
+
await awardManager.onContentCompleted(childId)
|
|
68
|
+
|
|
69
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
70
|
+
award._id,
|
|
71
|
+
100,
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
completedAt: expect.any(Number),
|
|
74
|
+
completionData: expect.objectContaining({
|
|
75
|
+
content_title: award.content_title,
|
|
76
|
+
days_user_practiced: expect.any(Number),
|
|
77
|
+
practice_minutes: expect.any(Number)
|
|
78
|
+
}),
|
|
79
|
+
progressData: {
|
|
80
|
+
completedLessonIds: [childId],
|
|
81
|
+
totalLessons: 1,
|
|
82
|
+
completedCount: 1
|
|
83
|
+
},
|
|
84
|
+
immediate: true
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('records 0% progress when the lesson is not completed', async () => {
|
|
91
|
+
mockNoneCompleted(db)
|
|
92
|
+
|
|
93
|
+
await awardManager.onContentCompleted(childId)
|
|
94
|
+
|
|
95
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
96
|
+
award._id,
|
|
97
|
+
0,
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
progressData: {
|
|
100
|
+
completedLessonIds: [],
|
|
101
|
+
totalLessons: 1,
|
|
102
|
+
completedCount: 0
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
expect(listeners.granted).not.toHaveBeenCalled()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('10-lesson course (content_id: 416450, child_ids: 10 lessons)', () => {
|
|
111
|
+
const award = getAwardByContentId(416450)
|
|
112
|
+
const childIds = award.child_ids
|
|
113
|
+
|
|
114
|
+
test('calculates 50% progress when 5 of 10 lessons are completed', async () => {
|
|
115
|
+
mockCompletionStates(db, childIds.slice(0, 5))
|
|
116
|
+
|
|
117
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
118
|
+
|
|
119
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
120
|
+
award._id,
|
|
121
|
+
50,
|
|
122
|
+
expect.objectContaining({
|
|
123
|
+
progressData: expect.objectContaining({
|
|
124
|
+
totalLessons: 10,
|
|
125
|
+
completedCount: 5
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('calculates 90% progress when 9 of 10 lessons are completed', async () => {
|
|
132
|
+
mockCompletionStates(db, childIds.slice(0, 9))
|
|
133
|
+
|
|
134
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
135
|
+
|
|
136
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
137
|
+
award._id,
|
|
138
|
+
90,
|
|
139
|
+
expect.objectContaining({
|
|
140
|
+
progressData: expect.objectContaining({
|
|
141
|
+
totalLessons: 10,
|
|
142
|
+
completedCount: 9
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
)
|
|
146
|
+
expect(listeners.granted).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('grants award when all 10 lessons are completed', async () => {
|
|
150
|
+
mockAllCompleted(db)
|
|
151
|
+
|
|
152
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
153
|
+
|
|
154
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
155
|
+
award._id,
|
|
156
|
+
100,
|
|
157
|
+
expect.objectContaining({
|
|
158
|
+
completedAt: expect.any(Number),
|
|
159
|
+
progressData: expect.objectContaining({
|
|
160
|
+
totalLessons: 10,
|
|
161
|
+
completedCount: 10
|
|
162
|
+
}),
|
|
163
|
+
immediate: true
|
|
164
|
+
})
|
|
165
|
+
)
|
|
166
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('22-lesson learning path (content_id: 417140)', () => {
|
|
171
|
+
const award = getAwardByContentId(417140)
|
|
172
|
+
const childIds = award.child_ids
|
|
173
|
+
|
|
174
|
+
test('calculates correct percentage for partial completion (11/22 = 50%)', async () => {
|
|
175
|
+
mockCompletionStates(db, childIds.slice(0, 11))
|
|
176
|
+
|
|
177
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
178
|
+
|
|
179
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
180
|
+
award._id,
|
|
181
|
+
50,
|
|
182
|
+
expect.objectContaining({
|
|
183
|
+
progressData: expect.objectContaining({
|
|
184
|
+
totalLessons: 22,
|
|
185
|
+
completedCount: 11
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('does not grant award when 21 of 22 lessons completed (95%)', async () => {
|
|
192
|
+
mockCompletionStates(db, childIds.slice(0, 21))
|
|
193
|
+
|
|
194
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
195
|
+
|
|
196
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
197
|
+
award._id,
|
|
198
|
+
95,
|
|
199
|
+
expect.objectContaining({
|
|
200
|
+
progressData: expect.objectContaining({
|
|
201
|
+
totalLessons: 22,
|
|
202
|
+
completedCount: 21
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
expect(listeners.granted).not.toHaveBeenCalled()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('grants award with correct popup message when all 22 lessons completed', async () => {
|
|
210
|
+
mockAllCompleted(db)
|
|
211
|
+
|
|
212
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
213
|
+
|
|
214
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
215
|
+
|
|
216
|
+
const payload = listeners.granted.mock.calls[0][0]
|
|
217
|
+
expect(payload.awardId).toBe(award._id)
|
|
218
|
+
expect(payload.popupMessage).toContain(award.content_title)
|
|
219
|
+
expect(payload.popupMessage).toContain('120 minutes')
|
|
220
|
+
expect(payload.completionData).toEqual(expect.objectContaining({
|
|
221
|
+
content_title: award.content_title,
|
|
222
|
+
practice_minutes: 120
|
|
223
|
+
}))
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('Award already completed', () => {
|
|
228
|
+
const award = getAwardByContentId(416446)
|
|
229
|
+
const childId = 416448
|
|
230
|
+
|
|
231
|
+
test('skips evaluation when award is already completed', async () => {
|
|
232
|
+
db.userAwardProgress.hasCompletedAward.mockResolvedValue(true)
|
|
233
|
+
mockAllCompleted(db)
|
|
234
|
+
|
|
235
|
+
await awardManager.onContentCompleted(childId)
|
|
236
|
+
|
|
237
|
+
expect(db.userAwardProgress.recordAwardProgress).not.toHaveBeenCalled()
|
|
238
|
+
expect(listeners.granted).not.toHaveBeenCalled()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
})
|