musora-content-services 2.160.0 → 2.160.2

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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(rg:*)",
5
+ "Bash(npm run lint:*)",
6
+ "Bash(ls:*)",
7
+ "Bash(npm test *)",
8
+ "Bash(npx jest *)"
9
+ ],
10
+ "deny": []
11
+ }
12
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [2.160.2](https://github.com/railroadmedia/musora-content-services/compare/v2.160.1...v2.160.2) (2026-05-14)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **BR-678:** fix issues with is_locked and collection ([#972](https://github.com/railroadmedia/musora-content-services/issues/972)) ([725529b](https://github.com/railroadmedia/musora-content-services/commit/725529bd69d05ab79113b21a5e3dca5ce1deb0dc))
11
+
12
+ ### [2.160.1](https://github.com/railroadmedia/musora-content-services/compare/v2.160.0...v2.160.1) (2026-05-13)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * disable debug code ([#970](https://github.com/railroadmedia/musora-content-services/issues/970)) ([4046c4f](https://github.com/railroadmedia/musora-content-services/commit/4046c4fca4457b9d21eab5221876a991bdf22973))
18
+
5
19
  ## [2.160.0](https://github.com/railroadmedia/musora-content-services/compare/v2.159.0...v2.160.0) (2026-05-13)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.160.0",
3
+ "version": "2.160.2",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -6,14 +6,20 @@ import { addContextToContent } from '../../contentAggregator.js'
6
6
  import { fetchByRailContentIds, fetchShows } from '../../sanity.js'
7
7
  import {
8
8
  postProcessBadge,
9
- collectionLessonTypes,
10
9
  getFormattedType,
11
10
  recentTypes,
12
11
  showsLessonTypes,
13
12
  songs,
13
+ getNextLessonLessonParentTypes,
14
14
  } from '../../../contentTypeConfig.js'
15
15
  import { PARENT_ID_TOP_LEVEL } from '../../sync/models/ContentProgress'
16
16
 
17
+ const excludeFromGeneratedIndex = [
18
+ 'getSubtitle',
19
+ 'getDefaultCTATextForContent',
20
+ 'getCompletedChildren',
21
+ ]
22
+
17
23
  /**
18
24
  * Fetch any content IDs with some progress, include the userPinnedItem,
19
25
  * and generate a map of the cards keyed by the content IDs
@@ -102,7 +108,7 @@ export async function processContentItem(content) {
102
108
  badge_logo: content.badge_logo ?? null,
103
109
  badge_template: content.badge_template ?? null,
104
110
  badge_template_rear: content.badge_template_rear ?? null,
105
- isLocked: content.is_locked ?? false,
111
+ isLocked: content.need_access ?? false,
106
112
  subtitle: getSubtitle(content, contentType, isLive),
107
113
  },
108
114
  cta: {
@@ -119,17 +125,21 @@ export async function processContentItem(content) {
119
125
  }
120
126
  }
121
127
 
122
- function getSubtitle(content, contentType, isLive) {
123
- if (collectionLessonTypes.includes(content.type) || content.lesson_count > 1) {
124
- return `${content.completed_children ?? 0} of ${content.all_children ?? content.lesson_count ?? content.child_count} Lessons Complete`
128
+ export function getSubtitle(content, contentType, isLive) {
129
+ if (getNextLessonLessonParentTypes.includes(content.type) || content.lesson_count > 1) {
130
+ const total = content.all_children ?? content.lesson_count ?? content.child_count
131
+ if (!total && total !== 0) return null
132
+ return `${content.completed_children ?? 0} of ${total} Lessons Complete`
125
133
  }
126
134
  if ((contentType === 'lesson' || contentType === 'show') && !isLive) {
135
+ if (content.progressPercentage == null) return null
127
136
  return `${content.progressPercentage}% Complete`
128
137
  }
129
- return `${content.difficulty_string} • ${content.artist_name}`
138
+ const parts = [content.difficulty_string, content.artist_name].filter(Boolean)
139
+ return parts.length ? parts.join(' • ') : null
130
140
  }
131
141
 
132
- function getDefaultCTATextForContent(content, contentType) {
142
+ export function getDefaultCTATextForContent(content, contentType) {
133
143
  const notStarted =
134
144
  !content.progressStatus ||
135
145
  content.progressStatus === 'not-started' ||
@@ -144,7 +154,7 @@ function getDefaultCTATextForContent(content, contentType) {
144
154
  )
145
155
  return 'Replay Song'
146
156
  if (contentType === 'lesson' || contentType === 'show') return 'Revisit Lesson'
147
- if (contentType === 'song tutorial' || collectionLessonTypes.includes(content.type))
157
+ if (getNextLessonLessonParentTypes.includes(content.type))
148
158
  return 'Revisit Lessons'
149
159
  if (contentType === 'course-collection') return 'View Lessons'
150
160
  }
@@ -152,7 +162,7 @@ function getDefaultCTATextForContent(content, contentType) {
152
162
  return 'Continue'
153
163
  }
154
164
 
155
- async function getCompletedChildren(content, contentType) {
165
+ export async function getCompletedChildren(content, contentType) {
156
166
  let completedChildren = 0
157
167
  let allChildren = 0
158
168
 
@@ -11,7 +11,7 @@ const SYNC_TABLES = ['progress', 'content_likes', 'practices', 'practice_day_not
11
11
 
12
12
  export async function repairStaleSyncedRecords(storesRegistry: Record<string, SyncStore<any>>) {
13
13
  const db = Object.values(storesRegistry)[0]!.db
14
- // if (await db.localStorage.get<string>(CLEANUP_FLAG_KEY)) return // todo
14
+ if (await db.localStorage.get<string>(CLEANUP_FLAG_KEY)) return
15
15
 
16
16
  const cutoff = Date.now() - STALE_CUTOFF_MS
17
17
  const payload: Record<string, [id: string, updated_at: EpochMs][]> = {}
@@ -0,0 +1,283 @@
1
+ import { initializeTestDB } from '../initializeTestDB'
2
+ import db from '../../../src/services/sync/repository-proxy'
3
+
4
+ jest.mock('../../../src/services/sanity.js', () => ({
5
+ ...jest.requireActual('../../../src/services/sanity.js'),
6
+ fetchShows: jest.fn(),
7
+ fetchByRailContentIds: jest.fn(),
8
+ }))
9
+ jest.mock('../../../src/services/contentAggregator.js', () => ({
10
+ addContextToContent: jest.fn(),
11
+ }))
12
+ jest.mock('../../../src/services/content-org/learning-paths.ts', () =>
13
+ require('../content-progress/__mocks__/mocks').mockLearningPaths(),
14
+ )
15
+ jest.mock('../../../src/services/awards/internal/content-progress-observer', () =>
16
+ require('../content-progress/__mocks__/mocks').mockContentProgressObserver(),
17
+ )
18
+ jest.mock('../../../src/services/progress-events', () =>
19
+ require('../content-progress/__mocks__/mocks').mockProgressEvents(),
20
+ )
21
+
22
+ import {
23
+ processContentItem,
24
+ getCompletedChildren,
25
+ } from '../../../src/services/progress-row/rows/content-card.js'
26
+ import { addContextToContent } from '../../../src/services/contentAggregator.js'
27
+
28
+ const lessonMeta = { brand: 'drumeo', type: 'lesson', parent_id: 0 }
29
+
30
+ initializeTestDB()
31
+
32
+ describe('getCompletedChildren', () => {
33
+ describe('show contentType', () => {
34
+ test('counts completed shows from addContextToContent result', async () => {
35
+ ;(addContextToContent as jest.Mock).mockResolvedValue([
36
+ { id: 1, progressStatus: 'completed' },
37
+ { id: 2, progressStatus: 'started' },
38
+ { id: 3, progressStatus: 'completed' },
39
+ { id: 4, progressStatus: 'not-started' },
40
+ ])
41
+ const content = { type: 'boot-camp', brand: 'drumeo' }
42
+ const result = await getCompletedChildren(content, 'show')
43
+ expect(result).toEqual({ completedChildren: 2, allChildren: 4 })
44
+ })
45
+
46
+ test('returns zero counts when no shows returned', async () => {
47
+ ;(addContextToContent as jest.Mock).mockResolvedValue([])
48
+ const content = { type: 'boot-camp', brand: 'drumeo' }
49
+ const result = await getCompletedChildren(content, 'show')
50
+ expect(result).toEqual({ completedChildren: 0, allChildren: 0 })
51
+ })
52
+
53
+ test('returns zero completed when no shows have completed status', async () => {
54
+ ;(addContextToContent as jest.Mock).mockResolvedValue([
55
+ { id: 1, progressStatus: 'started' },
56
+ { id: 2, progressStatus: 'not-started' },
57
+ ])
58
+ const content = { type: 'boot-camp', brand: 'drumeo' }
59
+ const result = await getCompletedChildren(content, 'show')
60
+ expect(result).toEqual({ completedChildren: 0, allChildren: 2 })
61
+ })
62
+ })
63
+
64
+ describe('non-show with children', () => {
65
+ test('counts completed children from real DB progress', async () => {
66
+ await db.contentProgress.recordProgress(101, null, 100, lessonMeta, undefined, { skipPush: true })
67
+ await db.contentProgress.recordProgress(102, null, 30, lessonMeta, undefined, { skipPush: true })
68
+ await db.contentProgress.recordProgress(103, null, 100, lessonMeta, undefined, { skipPush: true })
69
+
70
+ const content = {
71
+ type: 'course',
72
+ children: [{ id: 101 }, { id: 102 }, { id: 103 }],
73
+ }
74
+ const result = await getCompletedChildren(content, 'course')
75
+ expect(result).toEqual({ completedChildren: 2, allChildren: 3 })
76
+ })
77
+
78
+ test('recursively flattens nested children to leaf ids', async () => {
79
+ await db.contentProgress.recordProgress(201, null, 100, lessonMeta, undefined, { skipPush: true })
80
+ await db.contentProgress.recordProgress(202, null, 100, lessonMeta, undefined, { skipPush: true })
81
+ await db.contentProgress.recordProgress(203, null, 50, lessonMeta, undefined, { skipPush: true })
82
+
83
+ const content = {
84
+ type: 'course',
85
+ children: [
86
+ { id: 201 },
87
+ {
88
+ id: 999,
89
+ children: [{ id: 202 }, { id: 203 }],
90
+ },
91
+ ],
92
+ }
93
+ const result = await getCompletedChildren(content, 'course')
94
+ expect(result).toEqual({ completedChildren: 2, allChildren: 3 })
95
+ })
96
+
97
+ test('returns zero when no children have progress recorded', async () => {
98
+ const content = {
99
+ type: 'course',
100
+ children: [{ id: 901 }, { id: 902 }],
101
+ }
102
+ const result = await getCompletedChildren(content, 'course')
103
+ expect(result).toEqual({ completedChildren: 0, allChildren: 2 })
104
+ })
105
+ })
106
+
107
+ describe('non-show without children', () => {
108
+ test('returns zero counts when children is undefined', async () => {
109
+ const content = { type: 'song' }
110
+ const result = await getCompletedChildren(content, 'transcription')
111
+ expect(result).toEqual({ completedChildren: 0, allChildren: 0 })
112
+ })
113
+
114
+ test('returns zero counts when children is empty array', async () => {
115
+ const content = { type: 'song', children: [] }
116
+ const result = await getCompletedChildren(content, 'transcription')
117
+ expect(result).toEqual({ completedChildren: 0, allChildren: 0 })
118
+ })
119
+ })
120
+ })
121
+
122
+ describe('processContentItem', () => {
123
+ test('returns correctly shaped object for a song with no children', async () => {
124
+ const content = {
125
+ id: 500,
126
+ type: 'song',
127
+ brand: 'drumeo',
128
+ title: 'Test Song',
129
+ thumbnail: 'thumb.jpg',
130
+ progressPercentage: 40,
131
+ progressStatus: 'started',
132
+ progressTimestamp: 1700000000,
133
+ difficulty_string: 'Beginner',
134
+ artist_name: 'The Band',
135
+ slug: 'test-song',
136
+ navigateTo: 'child-slug',
137
+ }
138
+ const result = await processContentItem(content)
139
+ expect(result).toMatchObject({
140
+ id: 500,
141
+ progressType: 'content',
142
+ header: 'transcription',
143
+ pinned: false,
144
+ progressTimestamp: 1700000000,
145
+ body: {
146
+ progressPercent: 40,
147
+ thumbnail: 'thumb.jpg',
148
+ title: 'Test Song',
149
+ isLive: false,
150
+ brand: 'drumeo',
151
+ badge: null,
152
+ badge_rear: null,
153
+ badge_logo: null,
154
+ badge_template: null,
155
+ badge_template_rear: null,
156
+ isLocked: false,
157
+ subtitle: 'Beginner • The Band',
158
+ },
159
+ cta: {
160
+ text: 'Continue',
161
+ action: {
162
+ type: 'song',
163
+ brand: 'drumeo',
164
+ id: 500,
165
+ slug: 'test-song',
166
+ child: 'child-slug',
167
+ },
168
+ },
169
+ })
170
+ })
171
+
172
+ test('sets progressPercent to undefined when isLive=true', async () => {
173
+ const content = {
174
+ id: 1,
175
+ type: 'song',
176
+ brand: 'drumeo',
177
+ progressPercentage: 80,
178
+ isLive: true,
179
+ }
180
+ const result = await processContentItem(content)
181
+ expect(result.body.progressPercent).toBeUndefined()
182
+ expect(result.body.isLive).toBe(true)
183
+ })
184
+
185
+ test('isLive defaults to false when missing', async () => {
186
+ const content = { id: 2, type: 'song', brand: 'drumeo', progressPercentage: 50 }
187
+ const result = await processContentItem(content)
188
+ expect(result.body.isLive).toBe(false)
189
+ expect(result.body.progressPercent).toBe(50)
190
+ })
191
+
192
+ test('pinned defaults to false when missing', async () => {
193
+ const content = { id: 3, type: 'song', brand: 'drumeo' }
194
+ const result = await processContentItem(content)
195
+ expect(result.pinned).toBe(false)
196
+ })
197
+
198
+ test('pinned reflects content.pinned when set', async () => {
199
+ const content = { id: 4, type: 'song', brand: 'drumeo', pinned: true }
200
+ const result = await processContentItem(content)
201
+ expect(result.pinned).toBe(true)
202
+ })
203
+
204
+ test('isLocked is false when need_access is missing', async () => {
205
+ const content = { id: 5, type: 'song', brand: 'drumeo' }
206
+ const result = await processContentItem(content)
207
+ expect(result.body.isLocked).toBe(false)
208
+ })
209
+
210
+ test('isLocked is true when need_access is true', async () => {
211
+ const content = { id: 6, type: 'song', brand: 'drumeo', need_access: true }
212
+ const result = await processContentItem(content)
213
+ expect(result.body.isLocked).toBe(true)
214
+ })
215
+
216
+ test('passes through badge fields when set', async () => {
217
+ const content = {
218
+ id: 7,
219
+ type: 'song',
220
+ brand: 'drumeo',
221
+ badge: 'badge.png',
222
+ badge_rear: 'badge-rear.png',
223
+ badge_logo: 'logo.png',
224
+ badge_template: 'tpl',
225
+ badge_template_rear: 'tpl-rear',
226
+ }
227
+ const result = await processContentItem(content)
228
+ expect(result.body).toMatchObject({
229
+ badge: 'badge.png',
230
+ badge_rear: 'badge-rear.png',
231
+ badge_logo: 'logo.png',
232
+ badge_template: 'tpl',
233
+ badge_template_rear: 'tpl-rear',
234
+ })
235
+ })
236
+
237
+ test('populates completed_children/all_children for course with children', async () => {
238
+ await db.contentProgress.recordProgress(701, null, 100, lessonMeta, undefined, { skipPush: true })
239
+ await db.contentProgress.recordProgress(702, null, 100, lessonMeta, undefined, { skipPush: true })
240
+ await db.contentProgress.recordProgress(703, null, 25, lessonMeta, undefined, { skipPush: true })
241
+
242
+ const content: any = {
243
+ id: 700,
244
+ type: 'course',
245
+ brand: 'drumeo',
246
+ children: [{ id: 701 }, { id: 702 }, { id: 703 }],
247
+ }
248
+ const result = await processContentItem(content)
249
+ expect(content.completed_children).toBe(2)
250
+ expect(content.all_children).toBe(3)
251
+ expect(result.body.subtitle).toBe('2 of 3 Lessons Complete')
252
+ })
253
+
254
+ test('uses contentType from getFormattedType in header and cta resolution', async () => {
255
+ const content = {
256
+ id: 8,
257
+ type: 'guided-course',
258
+ brand: 'drumeo',
259
+ progressStatus: 'not-started',
260
+ }
261
+ const result = await processContentItem(content)
262
+ expect(result.header).toBe('guided course')
263
+ expect(result.cta.text).toBe('Start Course')
264
+ })
265
+
266
+ test('cta action mirrors content identity fields', async () => {
267
+ const content = {
268
+ id: 9,
269
+ type: 'song',
270
+ brand: 'pianote',
271
+ slug: 'my-slug',
272
+ navigateTo: 'next-child',
273
+ }
274
+ const result = await processContentItem(content)
275
+ expect(result.cta.action).toEqual({
276
+ type: 'song',
277
+ brand: 'pianote',
278
+ id: 9,
279
+ slug: 'my-slug',
280
+ child: 'next-child',
281
+ })
282
+ })
283
+ })
@@ -0,0 +1,314 @@
1
+ import {
2
+ getSubtitle,
3
+ getDefaultCTATextForContent,
4
+ } from '../../../src/services/progress-row/rows/content-card.js'
5
+
6
+ describe('getSubtitle', () => {
7
+ describe('parent lesson types', () => {
8
+ test.each([
9
+ 'course',
10
+ 'guided-course',
11
+ 'course-collection',
12
+ 'song-tutorial',
13
+ 'learning-path-v2',
14
+ 'skill-pack',
15
+ ])('returns "X of Y Lessons Complete" for type %s', (type) => {
16
+ const content = {
17
+ type,
18
+ completed_children: 3,
19
+ all_children: 10,
20
+ }
21
+ expect(getSubtitle(content, 'course', false)).toBe('3 of 10 Lessons Complete')
22
+ })
23
+
24
+ test('uses 0 when completed_children is missing', () => {
25
+ const content = { type: 'course', all_children: 8 }
26
+ expect(getSubtitle(content, 'course', false)).toBe('0 of 8 Lessons Complete')
27
+ })
28
+
29
+ test('falls back to lesson_count when all_children missing', () => {
30
+ const content = { type: 'course', completed_children: 2, lesson_count: 5 }
31
+ expect(getSubtitle(content, 'course', false)).toBe('2 of 5 Lessons Complete')
32
+ })
33
+
34
+ test('falls back to child_count when all_children and lesson_count missing', () => {
35
+ const content = { type: 'course', completed_children: 1, child_count: 4 }
36
+ expect(getSubtitle(content, 'course', false)).toBe('1 of 4 Lessons Complete')
37
+ })
38
+
39
+ test('prefers all_children over lesson_count and child_count', () => {
40
+ const content = {
41
+ type: 'course',
42
+ completed_children: 1,
43
+ all_children: 9,
44
+ lesson_count: 5,
45
+ child_count: 7,
46
+ }
47
+ expect(getSubtitle(content, 'course', false)).toBe('1 of 9 Lessons Complete')
48
+ })
49
+
50
+ test('returns null when all count fields missing', () => {
51
+ const content = { type: 'course', completed_children: 2 }
52
+ expect(getSubtitle(content, 'course', false)).toBeNull()
53
+ })
54
+ })
55
+
56
+ describe('lesson_count > 1', () => {
57
+ test('returns "X of Y Lessons Complete" when lesson_count > 1 even for non-parent type', () => {
58
+ const content = {
59
+ type: 'song',
60
+ completed_children: 2,
61
+ all_children: 6,
62
+ lesson_count: 6,
63
+ }
64
+ expect(getSubtitle(content, 'song', false)).toBe('2 of 6 Lessons Complete')
65
+ })
66
+
67
+ test('does not trigger when lesson_count is 1', () => {
68
+ const content = {
69
+ type: 'song',
70
+ lesson_count: 1,
71
+ difficulty_string: 'Beginner',
72
+ artist_name: 'Artist',
73
+ }
74
+ expect(getSubtitle(content, 'song', false)).toBe('Beginner • Artist')
75
+ })
76
+
77
+ test('does not trigger when lesson_count is 0', () => {
78
+ const content = {
79
+ type: 'song',
80
+ lesson_count: 0,
81
+ difficulty_string: 'Intermediate',
82
+ artist_name: 'Band',
83
+ }
84
+ expect(getSubtitle(content, 'song', false)).toBe('Intermediate • Band')
85
+ })
86
+ })
87
+
88
+ describe('lesson and show contentType', () => {
89
+ test('returns "X% Complete" for lesson when not live', () => {
90
+ const content = { type: 'course-lesson', progressPercentage: 42 }
91
+ expect(getSubtitle(content, 'lesson', false)).toBe('42% Complete')
92
+ })
93
+
94
+ test('returns "X% Complete" for show when not live', () => {
95
+ const content = { type: 'show', progressPercentage: 75 }
96
+ expect(getSubtitle(content, 'show', false)).toBe('75% Complete')
97
+ })
98
+
99
+ test('returns "0% Complete" when progressPercentage is 0', () => {
100
+ const content = { type: 'course-lesson', progressPercentage: 0 }
101
+ expect(getSubtitle(content, 'lesson', false)).toBe('0% Complete')
102
+ })
103
+
104
+ test('returns null when progressPercentage missing for lesson', () => {
105
+ const content = { type: 'course-lesson' }
106
+ expect(getSubtitle(content, 'lesson', false)).toBeNull()
107
+ })
108
+
109
+ test('returns null when progressPercentage missing for show', () => {
110
+ const content = { type: 'show' }
111
+ expect(getSubtitle(content, 'show', false)).toBeNull()
112
+ })
113
+
114
+ test('falls through to difficulty/artist when lesson isLive=true', () => {
115
+ const content = {
116
+ type: 'course-lesson',
117
+ progressPercentage: 80,
118
+ difficulty_string: 'Advanced',
119
+ artist_name: 'Live Artist',
120
+ }
121
+ expect(getSubtitle(content, 'lesson', true)).toBe('Advanced • Live Artist')
122
+ })
123
+
124
+ test('falls through to difficulty/artist when show isLive=true', () => {
125
+ const content = {
126
+ type: 'show',
127
+ progressPercentage: 50,
128
+ difficulty_string: 'Beginner',
129
+ artist_name: 'Live Show',
130
+ }
131
+ expect(getSubtitle(content, 'show', true)).toBe('Beginner • Live Show')
132
+ })
133
+ })
134
+
135
+ describe('default difficulty/artist subtitle', () => {
136
+ test('returns "difficulty • artist" for song', () => {
137
+ const content = {
138
+ type: 'song',
139
+ difficulty_string: 'Intermediate',
140
+ artist_name: 'The Beatles',
141
+ }
142
+ expect(getSubtitle(content, 'song', false)).toBe('Intermediate • The Beatles')
143
+ })
144
+
145
+ test('returns null when both difficulty and artist missing', () => {
146
+ const content = { type: 'song' }
147
+ expect(getSubtitle(content, 'song', false)).toBeNull()
148
+ })
149
+
150
+ test('returns difficulty only when artist missing', () => {
151
+ const content = { type: 'song', difficulty_string: 'Beginner' }
152
+ expect(getSubtitle(content, 'song', false)).toBe('Beginner')
153
+ })
154
+
155
+ test('returns artist only when difficulty missing', () => {
156
+ const content = { type: 'song', artist_name: 'The Beatles' }
157
+ expect(getSubtitle(content, 'song', false)).toBe('The Beatles')
158
+ })
159
+ })
160
+
161
+ describe('priority ordering', () => {
162
+ test('parent-type rule wins over lesson contentType', () => {
163
+ const content = {
164
+ type: 'course',
165
+ completed_children: 4,
166
+ all_children: 12,
167
+ progressPercentage: 33,
168
+ }
169
+ expect(getSubtitle(content, 'lesson', false)).toBe('4 of 12 Lessons Complete')
170
+ })
171
+
172
+ test('lesson_count>1 rule wins over lesson contentType', () => {
173
+ const content = {
174
+ type: 'song',
175
+ lesson_count: 3,
176
+ completed_children: 1,
177
+ all_children: 3,
178
+ progressPercentage: 33,
179
+ }
180
+ expect(getSubtitle(content, 'lesson', false)).toBe('1 of 3 Lessons Complete')
181
+ })
182
+ })
183
+ })
184
+
185
+ describe('getDefaultCTATextForContent', () => {
186
+ describe('guided-course not started', () => {
187
+ test('returns "Start Course" when progressStatus is undefined', () => {
188
+ const content = { type: 'guided-course' }
189
+ expect(getDefaultCTATextForContent(content, 'guided course')).toBe('Start Course')
190
+ })
191
+
192
+ test('returns "Start Course" when progressStatus is "not-started"', () => {
193
+ const content = { type: 'guided-course', progressStatus: 'not-started' }
194
+ expect(getDefaultCTATextForContent(content, 'guided course')).toBe('Start Course')
195
+ })
196
+
197
+ test('returns "Start Course" when progressPercentage is 0', () => {
198
+ const content = {
199
+ type: 'guided-course',
200
+ progressStatus: 'started',
201
+ progressPercentage: 0,
202
+ }
203
+ expect(getDefaultCTATextForContent(content, 'guided course')).toBe('Start Course')
204
+ })
205
+
206
+ test('returns "Continue" when guided-course is in-progress', () => {
207
+ const content = {
208
+ type: 'guided-course',
209
+ progressStatus: 'started',
210
+ progressPercentage: 25,
211
+ }
212
+ expect(getDefaultCTATextForContent(content, 'guided course')).toBe('Continue')
213
+ })
214
+ })
215
+
216
+ describe('completed - song variants', () => {
217
+ test.each([
218
+ ['drumeo', 'transcription'],
219
+ ['guitareo', 'tab'],
220
+ ['pianote', 'sheet music'],
221
+ ['singeo', 'sheet music'],
222
+ ['playbass', 'tab'],
223
+ ])('returns "Replay Song" when completed for brand %s with song contentType %s', (brand, contentType) => {
224
+ const content = { type: 'song', brand, progressStatus: 'completed' }
225
+ expect(getDefaultCTATextForContent(content, contentType)).toBe('Replay Song')
226
+ })
227
+
228
+ test('returns "Replay Song" when completed and contentType is "play along"', () => {
229
+ const content = { type: 'play-along', brand: 'drumeo', progressStatus: 'completed' }
230
+ expect(getDefaultCTATextForContent(content, 'play along')).toBe('Replay Song')
231
+ })
232
+
233
+ test('returns "Replay Song" when completed and contentType is "jam track"', () => {
234
+ const content = { type: 'jam-track', brand: 'drumeo', progressStatus: 'completed' }
235
+ expect(getDefaultCTATextForContent(content, 'jam track')).toBe('Replay Song')
236
+ })
237
+
238
+ test('does not return "Replay Song" when brand song contentType mismatches', () => {
239
+ const content = { type: 'song', brand: 'drumeo', progressStatus: 'completed' }
240
+ expect(getDefaultCTATextForContent(content, 'tab')).toBe('Continue')
241
+ })
242
+ })
243
+
244
+ describe('completed - lesson/show', () => {
245
+ test('returns "Revisit Lesson" when completed lesson', () => {
246
+ const content = { type: 'course-lesson', progressStatus: 'completed' }
247
+ expect(getDefaultCTATextForContent(content, 'lesson')).toBe('Revisit Lesson')
248
+ })
249
+
250
+ test('returns "Revisit Lesson" when completed show', () => {
251
+ const content = { type: 'show', progressStatus: 'completed' }
252
+ expect(getDefaultCTATextForContent(content, 'show')).toBe('Revisit Lesson')
253
+ })
254
+ })
255
+
256
+ describe('completed - parent lesson types', () => {
257
+ test.each([
258
+ 'course',
259
+ 'guided-course',
260
+ 'course-collection',
261
+ 'song-tutorial',
262
+ 'learning-path-v2',
263
+ 'skill-pack',
264
+ ])('returns "Revisit Lessons" when completed for type %s', (type) => {
265
+ const content = { type, progressStatus: 'completed' }
266
+ expect(getDefaultCTATextForContent(content, 'course')).toBe('Revisit Lessons')
267
+ })
268
+ })
269
+
270
+ describe('completed - other', () => {
271
+ test('returns "Continue" when completed and no rule matches', () => {
272
+ const content = { type: 'some-type', progressStatus: 'completed' }
273
+ expect(getDefaultCTATextForContent(content, 'unknown')).toBe('Continue')
274
+ })
275
+ })
276
+
277
+ describe('default fallback', () => {
278
+ test('returns "Continue" when no progressStatus for non-guided-course', () => {
279
+ const content = { type: 'song' }
280
+ expect(getDefaultCTATextForContent(content, 'song')).toBe('Continue')
281
+ })
282
+
283
+ test('returns "Continue" when progressStatus is "started"', () => {
284
+ const content = {
285
+ type: 'course-lesson',
286
+ progressStatus: 'started',
287
+ progressPercentage: 50,
288
+ }
289
+ expect(getDefaultCTATextForContent(content, 'lesson')).toBe('Continue')
290
+ })
291
+
292
+ test('returns "Continue" when not-started and not guided-course', () => {
293
+ const content = { type: 'course-lesson', progressStatus: 'not-started' }
294
+ expect(getDefaultCTATextForContent(content, 'lesson')).toBe('Continue')
295
+ })
296
+ })
297
+
298
+ describe('priority ordering', () => {
299
+ test('guided-course not-started wins even when contentType is lesson', () => {
300
+ const content = { type: 'guided-course', progressPercentage: 0 }
301
+ expect(getDefaultCTATextForContent(content, 'lesson')).toBe('Start Course')
302
+ })
303
+
304
+ test('completed song-match wins over completed lesson contentType', () => {
305
+ const content = { type: 'song', brand: 'drumeo', progressStatus: 'completed' }
306
+ expect(getDefaultCTATextForContent(content, 'transcription')).toBe('Replay Song')
307
+ })
308
+
309
+ test('completed lesson contentType wins over parent-type rule', () => {
310
+ const content = { type: 'course', progressStatus: 'completed' }
311
+ expect(getDefaultCTATextForContent(content, 'lesson')).toBe('Revisit Lesson')
312
+ })
313
+ })
314
+ })