diff --git a/mobile-app/lib/models/learn/curriculum_model.dart b/mobile-app/lib/models/learn/curriculum_model.dart index 0cdf17463..1339b8cc6 100644 --- a/mobile-app/lib/models/learn/curriculum_model.dart +++ b/mobile-app/lib/models/learn/curriculum_model.dart @@ -228,6 +228,11 @@ class Block { return BlockLabel.fromValue(type); } + final challengeOrder = _scopedChallengeOrder( + data['challengeOrder'] as List, + superBlockDashedName, + ); + return Block( superBlock: SuperBlock( dashedName: superBlockDashedName, @@ -242,24 +247,20 @@ class Block { dashedName: dashedName, description: finalDescription, order: data['order'], - challenges: (data['challengeOrder'] as List) + challenges: challengeOrder .map( (dynamic challenge) => ChallengeOrder( - id: challenge[0] ?? challenge['id'], - title: challenge[1] ?? challenge['title'], + id: _challengeId(challenge), + title: _challengeTitle(challenge), ), ) .toList(), - challengeTiles: (data['challengeOrder'] as List) + challengeTiles: challengeOrder .map( (dynamic challenge) => ChallengeListTile( - id: challenge[0] ?? challenge['id'], - name: challenge[1] ?? challenge['title'], - dashedName: challenge[1] ?? - challenge['title'] - .toLowerCase() - .replaceAll(' ', '-') - .replaceAll(RegExp(r"[@':]"), ''), + id: _challengeId(challenge), + name: _challengeTitle(challenge), + dashedName: _challengeDashedName(challenge), ), ) .toList(), @@ -267,6 +268,71 @@ class Block { } } +String _challengeId(dynamic challenge) { + if (challenge is List) { + return challenge[0]; + } + + return challenge['id']; +} + +String _challengeTitle(dynamic challenge) { + if (challenge is List) { + return challenge[1]; + } + + return challenge['title']; +} + +String _challengeDashedName(dynamic challenge) { + if (challenge is Map && challenge['dashedName'] != null) { + return challenge['dashedName']; + } + + return _challengeTitle(challenge) + .toLowerCase() + .replaceAll(' ', '-') + .replaceAll(RegExp(r"[@':]"), ''); +} + +List _scopedChallengeOrder( + List challengeOrder, + String superBlockDashedName, +) { + final seenIds = {}; + final scopedChallengeOrder = []; + + for (final challenge in challengeOrder) { + if (!_belongsToSuperBlock(challenge, superBlockDashedName)) { + continue; + } + + final id = _challengeId(challenge); + + if (seenIds.add(id)) { + scopedChallengeOrder.add(challenge); + } + } + + return scopedChallengeOrder; +} + +bool _belongsToSuperBlock(dynamic challenge, String superBlockDashedName) { + if (challenge is! Map) { + return true; + } + + // Prefer superBlock when it is present because it is the direct curriculum + // route segment the mobile app uses to fetch block and challenge data. + final challengeSuperBlock = + challenge['superBlock'] ?? challenge['superblock']; + if (challengeSuperBlock != null) { + return challengeSuperBlock == superBlockDashedName; + } + + return true; +} + class ChallengeListTile { final String id; final String name; diff --git a/mobile-app/test/unit/curriculum_model_test.dart b/mobile-app/test/unit/curriculum_model_test.dart new file mode 100644 index 000000000..ea7beaabf --- /dev/null +++ b/mobile-app/test/unit/curriculum_model_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:freecodecamp/models/learn/curriculum_model.dart'; + +void main() { + group('Block.fromJson', () { + test('filters challenge order entries from other superblocks', () { + final block = Block.fromJson( + { + 'name': 'Build a Greeting Bot', + 'blockLayout': 'challenge-grid', + 'blockLabel': 'workshop', + 'challengeOrder': [ + { + 'id': 'js-step-1', + 'title': 'Step 1', + 'superBlock': 'javascript-v9', + }, + { + 'id': 'js-step-2', + 'title': 'Step 2', + 'superBlock': 'javascript-v9', + }, + { + 'id': 'rwd-step-1', + 'title': 'Step 1', + 'superBlock': 'responsive-web-design-v9', + }, + { + 'id': 'rwd-step-2', + 'title': 'Step 2', + 'superBlock': 'responsive-web-design-v9', + }, + ], + }, + [], + 'workshop-greeting-bot', + 'javascript-v9', + 'JavaScript Certification', + ); + + expect(block.challenges.map((challenge) => challenge.id), [ + 'js-step-1', + 'js-step-2', + ]); + expect(block.challengeTiles.map((challenge) => challenge.id), [ + 'js-step-1', + 'js-step-2', + ]); + }); + + test('deduplicates repeated challenge ids', () { + final block = Block.fromJson( + { + 'name': 'Build a Greeting Bot', + 'blockLayout': 'challenge-grid', + 'blockLabel': 'workshop', + 'challengeOrder': [ + {'id': 'step-1', 'title': 'Step 1'}, + {'id': 'step-2', 'title': 'Step 2'}, + {'id': 'step-1', 'title': 'Step 1'}, + {'id': 'step-2', 'title': 'Step 2'}, + ], + }, + [], + 'workshop-greeting-bot', + 'javascript-v9', + 'JavaScript Certification', + ); + + expect(block.challenges.map((challenge) => challenge.id), [ + 'step-1', + 'step-2', + ]); + expect(block.challengeTiles.length, 2); + }); + + test('supports legacy list challenge order entries', () { + final block = Block.fromJson( + { + 'name': 'Legacy Block', + 'blockLayout': 'challenge-list', + 'blockLabel': 'legacy', + 'challengeOrder': [ + ['step-1', 'Step 1'], + ['step-2', 'Step 2'], + ], + }, + [], + 'legacy-block', + 'responsive-web-design', + 'Responsive Web Design', + ); + + expect(block.challenges.map((challenge) => challenge.id), [ + 'step-1', + 'step-2', + ]); + expect(block.challengeTiles.map((challenge) => challenge.dashedName), [ + 'step-1', + 'step-2', + ]); + }); + }); +}