diff --git a/app/core/utils.coffee b/app/core/utils.coffee index 5cc98cd5b..028105538 100644 --- a/app/core/utils.coffee +++ b/app/core/utils.coffee @@ -325,3 +325,48 @@ module.exports.capitalLanguages = capitalLanguages = 'python': 'Python' 'java': 'Java' 'lua': 'Lua' + +module.exports.createLevelNumberMap = (levels) -> + levelNumberMap = {} + practiceLevelTotalCount = 0 + practiceLevelCurrentCount = 0 + for level, i in levels + levelNumber = i - practiceLevelTotalCount + 1 + if level.practice + levelNumber = i - practiceLevelTotalCount + String.fromCharCode('a'.charCodeAt(0) + practiceLevelCurrentCount) + practiceLevelTotalCount++ + practiceLevelCurrentCount++ + else + practiceLevelCurrentCount = 0 + levelNumberMap[level.key] = levelNumber + levelNumberMap + +module.exports.findNextLevel = (levels, currentIndex, needsPractice) -> + # levels = [{practice: true/false, complete: true/false}] + index = currentIndex + index++ + if needsPractice + if levels[currentIndex].practice or index < levels.length and levels[index].practice + # Needs practice, on practice or next practice, choose next incomplete level + # May leave earlier practice levels incomplete and reach end of course + index++ while index < levels.length and levels[index].complete + else + # Needs practice, on required, next required, choose first incomplete level of previous practice chain + index-- + index-- while index >= 0 and not levels[index].practice + if index >= 0 + index-- while index >= 0 and levels[index].practice + if index >= 0 + index++ + index++ while index < levels.length and levels[index].practice and levels[index].complete + if levels[index].practice and not levels[index].complete + return index + index = currentIndex + 1 + index++ while index < levels.length and levels[index].complete + else + # No practice needed, next required incomplete level + index++ while index < levels.length and (levels[index].practice or levels[index].complete) + index + +module.exports.needsPractice = (playtime=0, threshold=2) -> + playtime / 60 > threshold diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 3bf4586f5..04e781e9d 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -295,6 +295,7 @@ saving: "Saving..." sending: "Sending..." send: "Send" + type: "Type" cancel: "Cancel" save: "Save" publish: "Publish" diff --git a/app/models/Campaign.coffee b/app/models/Campaign.coffee index 2e5cb9db1..c72b072f3 100644 --- a/app/models/Campaign.coffee +++ b/app/models/Campaign.coffee @@ -3,6 +3,7 @@ schema = require 'schemas/models/campaign.schema' Level = require 'models/Level' Levels = require 'collections/Levels' CocoCollection = require 'collections/CocoCollection' +utils = require '../core/utils' module.exports = class Campaign extends CocoModel @className: 'Campaign' @@ -23,3 +24,11 @@ module.exports = class Campaign extends CocoModel levels.comparator = 'campaignIndex' levels.sort() return levels + + getLevelNumber: (levelID, defaultNumber) -> + unless @levelNumberMap + levels = [] + for level in @getLevels().models when level.get('original') + levels.push({key: level.get('original'), practice: level.get('practice') ? false}) + @levelNumberMap = utils.createLevelNumberMap(levels) + @levelNumberMap[levelID] ? defaultNumber diff --git a/app/models/Classroom.coffee b/app/models/Classroom.coffee index d3564fab5..ef47355b0 100644 --- a/app/models/Classroom.coffee +++ b/app/models/Classroom.coffee @@ -1,17 +1,17 @@ CocoModel = require './CocoModel' schema = require 'schemas/models/classroom.schema' -utils = require 'core/utils' +utils = require '../core/utils' User = require 'models/User' module.exports = class Classroom extends CocoModel @className: 'Classroom' @schema: schema urlRoot: '/db/classroom' - + initialize: () -> @listenTo @, 'change:aceConfig', @capitalizeLanguageName super(arguments...) - + parse: (obj) -> if obj._id # It's just the classroom object @@ -20,7 +20,7 @@ module.exports = class Classroom extends CocoModel # It's a compound response with other stuff too @owner = new User(obj.owner) return obj.data - + capitalizeLanguageName: -> language = @get('aceConfig')?.language @capitalLanguage = utils.capitalLanguages[language] @@ -35,7 +35,7 @@ module.exports = class Classroom extends CocoModel } _.extend options, opts @fetch(options) - + fetchByCode: (code, opts) -> options = { url: _.result(@, 'url') @@ -43,7 +43,17 @@ module.exports = class Classroom extends CocoModel } _.extend options, opts @fetch(options) - + + getLevelNumber: (levelID, defaultNumber) -> + unless @levelNumberMap + @levelNumberMap = {} + for course in @get('courses') ? [] + levels = [] + for level in course.levels when level.original + levels.push({key: level.original, practice: level.practice ? false}) + _.assign(@levelNumberMap, utils.createLevelNumberMap(levels)) + @levelNumberMap[levelID] ? defaultNumber + removeMember: (userID, opts) -> options = { url: _.result(@, 'url') + '/members' @@ -62,7 +72,7 @@ module.exports = class Classroom extends CocoModel success: => @trigger 'save-password:success' error: (response) => @trigger 'save-password:error', response.responseJSON } - + getLevels: (options={}) -> # options: courseID, withoutLadderLevels Levels = require 'collections/Levels' @@ -77,7 +87,7 @@ module.exports = class Classroom extends CocoModel if options.withoutLadderLevels levels.remove(levels.filter((level) -> level.isLadder())) return levels - + getLadderLevel: (courseID) -> Levels = require 'collections/Levels' courses = @get('courses') @@ -88,30 +98,59 @@ module.exports = class Classroom extends CocoModel statsForSessions: (sessions, courseID) -> return null unless sessions - stats = {} sessions = sessions.models or sessions arena = @getLadderLevel(courseID) - levels = @getLevels({courseID: courseID, withoutLadderLevels: true}) - levelOriginals = levels.pluck('original') - completeSessionOriginals = (session.get('level').original for session in sessions when session.get('state').complete) - incompleteSessionOriginals = (session.get('level').original for session in sessions when not session.get('state').complete) - levelsLeft = _.size(_.difference(levelOriginals, completeSessionOriginals)) - next = _.find levels.models, (level) -> level.get('original') not in completeSessionOriginals - lastPlayed = _.find levels.models, (level) -> level.get('original') in incompleteSessionOriginals - stats.levels = { - size: levels.size() - left: levelsLeft - done: levelsLeft is 0 - numDone: levels.size() - levelsLeft - pctDone: (100 * (levels.size() - levelsLeft) / levels.size()).toFixed(1) + '%' - lastPlayed: lastPlayed - next: next - first: levels.first() - arena: arena - } - sum = (nums) -> _.reduce(nums, (s, num) -> s + num) or 0 - stats.playtime = sum((session.get('playtime') or 0 for session in sessions)) - return stats + courseLevels = @getLevels({courseID: courseID, withoutLadderLevels: true}) + levelSessionMap = {} + levelSessionMap[session.get('level').original] = session for session in sessions + currentIndex = -1 + lastStarted = null + levelsTotal = 0 + levelsLeft = 0 + lastPlayed = null + playtime = 0 + levels = [] + for level, index in courseLevels.models + levelsTotal++ unless level.get('practice') + complete = false + if session = levelSessionMap[level.get('original')] + complete = session.get('state').complete ? false + playtime += session.get('playtime') ? 0 + lastPlayed = level + if complete + currentIndex = index + else + lastStarted = level + levelsLeft++ unless level.get('practice') + else if not level.get('practice') + levelsLeft++ + levels.push + practice: level.get('practice') ? false + complete: complete + lastPlayed = lastStarted ? lastPlayed + needsPractice = false + nextIndex = 0 + if currentIndex >= 0 + currentLevel = courseLevels.models[currentIndex] + currentPlaytime = levelSessionMap[currentLevel.get('original')]?.get('playtime') ? 0 + needsPractice = utils.needsPractice(currentPlaytime, currentLevel.get('practiceThresholdMinutes')) + nextIndex = utils.findNextLevel(levels, currentIndex, needsPractice) + nextLevel = courseLevels.models[nextIndex] + nextLevel ?= _.find courseLevels.models, (level) -> not levelSessionMap[level.get('original')]?.get('state')?.complete + + stats = + levels: + size: levelsTotal + left: levelsLeft + done: levelsLeft is 0 + numDone: levelsTotal - levelsLeft + pctDone: (100 * (levelsTotal - levelsLeft) / levelsTotal).toFixed(1) + '%' + lastPlayed: lastPlayed + next: nextLevel + first: courseLevels.first() + arena: arena + playtime: playtime + stats fetchForCourseInstance: (courseInstanceID, options={}) -> return unless courseInstanceID diff --git a/app/models/Level.coffee b/app/models/Level.coffee index b3f6d9949..8df696750 100644 --- a/app/models/Level.coffee +++ b/app/models/Level.coffee @@ -262,9 +262,9 @@ module.exports = class Level extends CocoModel isLadder: -> return @get('type')?.indexOf('ladder') > -1 - fetchNextForCourse: ({ levelOriginalID, courseInstanceID, courseID }, options={}) -> + fetchNextForCourse: ({ levelOriginalID, courseInstanceID, courseID, sessionID }, options={}) -> if courseInstanceID - options.url = "/db/course_instance/#{courseInstanceID}/levels/#{levelOriginalID}/next" + options.url = "/db/course_instance/#{courseInstanceID}/levels/#{levelOriginalID}/sessions/#{sessionID}/next" else options.url = "/db/course/#{courseID}/levels/#{levelOriginalID}/next" @fetch(options) diff --git a/app/schemas/models/campaign.schema.coffee b/app/schemas/models/campaign.schema.coffee index 182d62771..d5e0a1c8a 100644 --- a/app/schemas/models/campaign.schema.coffee +++ b/app/schemas/models/campaign.schema.coffee @@ -66,6 +66,7 @@ _.extend CampaignSchema.properties, { original: { type: 'string', format: 'hidden' } adventurer: { type: 'boolean' } practice: { type: 'boolean' } + practiceThresholdMinutes: {type: 'number'} adminOnly: { type: 'boolean' } disableSpaces: { type: ['boolean','number'] } hidesSubmitUntilRun: { type: 'boolean' } diff --git a/app/schemas/models/classroom.schema.coffee b/app/schemas/models/classroom.schema.coffee index 9c6a7fe4c..c06aebe2e 100644 --- a/app/schemas/models/classroom.schema.coffee +++ b/app/schemas/models/classroom.schema.coffee @@ -23,6 +23,8 @@ _.extend ClassroomSchema.properties, courses: c.array { title: 'Courses' }, c.object { title: 'Course' }, { _id: c.objectId() levels: c.array { title: 'Levels' }, c.object { title: 'Level' }, { + practice: {type: 'boolean'} + practiceThresholdMinutes: {type: 'number'} type: c.shortString() original: c.objectId() name: {type: 'string'} diff --git a/app/templates/courses/classroom-view.jade b/app/templates/courses/classroom-view.jade index e7cc3607d..bbe33e7ed 100644 --- a/app/templates/courses/classroom-view.jade +++ b/app/templates/courses/classroom-view.jade @@ -32,10 +32,6 @@ block content td(data-i18n="courses.total_students") td span.spr= _.size(view.classroom.get('members')) - span ( - span.spr(data-i18n="courses.enrolled_courses") - span= stats.enrolledUsers - span ) tr td(data-i18n="courses.average_time") td= stats.averagePlaytime @@ -108,16 +104,17 @@ block content .progress each trimModel in levels.models - var level = view.levels.get(trimModel.get('original')); // get the level loaded through the db + - var levelNumber = view.classroom.getLevelNumber(level.get('original'), i + 1) - i++ - var session = sessionMap[level.get('original')]; a(href=view.getLevelURL(level, course, courseInstance, session)) - - var content = view.levelPopoverContent(level, session, i); + - var content = view.levelPopoverContent(level, session, levelNumber); if session && session.get('state') && session.get('state').complete - .progress-bar.progress-bar-complete(style=css, data-content=content, data-toggle='popover')= i + .progress-bar.progress-bar-complete(style=css, data-content=content, data-toggle='popover')= levelNumber else if session - .progress-bar.progress-bar-started(style=css, data-content=content, data-toggle='popover')= i + .progress-bar.progress-bar-started(style=css, data-content=content, data-toggle='popover')= levelNumber else - .progress-bar.progress-bar-default(style=css, data-content=content, data-toggle='popover')= i + .progress-bar.progress-bar-default(style=css, data-content=content, data-toggle='popover')= levelNumber else if paidFor .text-center button.enable-btn.btn.btn-info.btn-sm.text-uppercase(data-user-id=user.id, data-course-instance-cid=courseInstance.cid) diff --git a/app/templates/courses/course-details.jade b/app/templates/courses/course-details.jade index e47e37282..6118f447b 100644 --- a/app/templates/courses/course-details.jade +++ b/app/templates/courses/course-details.jade @@ -88,6 +88,7 @@ block content tr th th(data-i18n="clans.status") + th(data-i18n="common.type") th(data-i18n="resources.level") th(data-i18n="courses.concepts") tbody @@ -97,6 +98,7 @@ block content - var levelCount = 0; each level in view.levels.models - var levelStatus = null; + - var levelNumber = view.classroom.getLevelNumber(level.get('original'), ++levelCount); if view.userLevelStateMap[me.id] - levelStatus = view.userLevelStateMap[me.id][level.get('original')] tr @@ -107,10 +109,8 @@ block content td if view.userLevelStateMap[me.id] div= view.userLevelStateMap[me.id][level.get('original')] - - previousLevelCompleted = view.userLevelStateMap[me.id][level.get('original')] === 'complete' - else - - previousLevelCompleted = false - td= ++levelCount + '. ' + level.get('name').replace('Course: ', '') + td #{level.get('practice') ? 'practice' : 'required'} + td #{levelNumber}. #{level.get('name').replace('Course: ', '')} td if view.levelConceptMap[level.get('original')] each concept in view.course.get('concepts') @@ -118,3 +118,8 @@ block content span.spr.concept(data-i18n="concepts." + concept) if level.get('original') === lastLevelCompleted - passedLastCompletedLevel = true + if !level.get('practice') + if view.userLevelStateMap[me.id] + - previousLevelCompleted = view.userLevelStateMap[me.id][level.get('original')] === 'complete' + else + - previousLevelCompleted = false diff --git a/app/templates/courses/teacher-class-view.jade b/app/templates/courses/teacher-class-view.jade index 453f8485c..8faf7e7a4 100644 --- a/app/templates/courses/teacher-class-view.jade +++ b/app/templates/courses/teacher-class-view.jade @@ -309,7 +309,8 @@ mixin courseOverview .course-overview-progress each level, index in levels - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level }) - +allStudentsLevelProgressDot(progress, level, index+1) + - var levelNumber = view.classroom.getLevelNumber(level.get('original'), index + 1) + +allStudentsLevelProgressDot(progress, level, levelNumber) mixin studentLevelsRow(student) .student-levels-row.alternating-background @@ -321,7 +322,8 @@ mixin studentLevelsRow(student) - var levels = view.classroom.getLevels({courseID: course.id}).models each level, index in levels - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student }) - +studentLevelProgressDot(progress, level, index+1, session) + - var levelNumber = view.classroom.getLevelNumber(level.get('original'), index + 1) + +studentLevelProgressDot(progress, level, levelNumber, session) mixin studentCourseProgressDot(progress, levelsTotal, level, label) //- TODO: Refactor with TeacherClassesView jade diff --git a/app/templates/courses/teacher-courses-view.jade b/app/templates/courses/teacher-courses-view.jade index 91e53fddd..c4a8b5673 100644 --- a/app/templates/courses/teacher-courses-view.jade +++ b/app/templates/courses/teacher-courses-view.jade @@ -56,12 +56,12 @@ block content | : select.level-select.form-control if view.campaigns.loaded - each level, levelIndex in view.campaigns.get(course.get('campaignID')).getLevels().models - if level.get('practice') - - continue; + - var campaign = view.campaigns.get(course.get('campaignID')) + each level, levelIndex in campaign.getLevels().models + - var levelNumber = campaign.getLevelNumber(level.get('original'), levelIndex + 1) option(value=level.get('slug')) span - = levelIndex + 1 + = levelNumber span.spr | . span diff --git a/app/templates/play/level/control_bar.jade b/app/templates/play/level/control_bar.jade index eee99d960..639a389f8 100644 --- a/app/templates/play/level/control_bar.jade +++ b/app/templates/play/level/control_bar.jade @@ -22,7 +22,7 @@ else .level-name-area .level-label(data-i18n="play_level.level") .level-name(title=difficultyTitle || "") - span= (campaignIndex ? campaignIndex + '. ' : '') + worldName.replace('Course: ', '') + span #{view.levelNumber ? view.levelNumber + '. ' : ''}#{worldName.replace('Course: ', '')} if levelDifficulty sup.level-difficulty= levelDifficulty diff --git a/app/templates/teachers/hovers/progress-dot-all-students-single-level.jade b/app/templates/teachers/hovers/progress-dot-all-students-single-level.jade index 2a04ae2f5..e139717ec 100644 --- a/app/templates/teachers/hovers/progress-dot-all-students-single-level.jade +++ b/app/templates/teachers/hovers/progress-dot-all-students-single-level.jade @@ -17,5 +17,9 @@ if started //- .small-details //- i(data-i18n='teacher.click_to_view_progress') else + .small-details.nowrap + span= levelNumber + span.spr . + span= levelName span.small-details.nowrap(data-i18n='teacher.no_progress') | No progress diff --git a/app/templates/teachers/hovers/progress-dot-single-student-level.jade b/app/templates/teachers/hovers/progress-dot-single-student-level.jade index 0a8433a48..0c77f5853 100644 --- a/app/templates/teachers/hovers/progress-dot-single-student-level.jade +++ b/app/templates/teachers/hovers/progress-dot-single-student-level.jade @@ -13,7 +13,7 @@ if completed .small-details.nowrap span.spr(data-i18n='teacher.completed') | Completed - span= new Date(dateFirstCompleted).toLocaleString() + span= new Date(session.get('dateFirstCompleted')).toLocaleString() +timePlayed //- .small-details //- i(data-i18n='teacher.click_to_view_solution') @@ -26,11 +26,15 @@ else if started .small-details.nowrap span.spr(data-i18n='teacher.last_played') | Last played - span= new Date(lastPlayed).toLocaleString() + span= new Date(session.get('changed')).toLocaleString() +timePlayed //- .small-details //- i(data-i18n='teacher.click_to_view_progress') //- | click to view progress else + .small-details.nowrap + span= levelNumber + span.spr . + span= levelName span.small-details.nowrap(data-i18n='teacher.no_progress') | No progress diff --git a/app/views/courses/ClassroomView.coffee b/app/views/courses/ClassroomView.coffee index e7f9f1c51..fb4431f95 100644 --- a/app/views/courses/ClassroomView.coffee +++ b/app/views/courses/ClassroomView.coffee @@ -55,7 +55,7 @@ module.exports = class ClassroomView extends RootView @ownedClassrooms.fetchMine({data: {project: '_id'}}) @supermodel.trackCollection(@ownedClassrooms) @levels = new Levels() - @levels.fetchForClassroom(classroomID, {data: {project: 'name,slug,original'}}) + @levels.fetchForClassroom(classroomID, {data: {project: 'name,original,practice,slug'}}) @levels.on 'add', (model) -> @_byId[model.get('original')] = model # so you can 'get' them @supermodel.trackCollection(@levels) window.tracker?.trackEvent 'Students Class Loaded', category: 'Students', classroomID: classroomID, ['Mixpanel'] @@ -177,7 +177,9 @@ module.exports = class ClassroomView extends RootView stats.averagePlaytime = if playtime and total then moment.duration(playtime / total, "seconds").humanize() else 0 stats.totalPlaytime = if playtime then moment.duration(playtime, "seconds").humanize() else 0 - completeSessions = @sessions.filter (s) -> s.get('state')?.complete + levelPracticeMap = {} + levelPracticeMap[level.id] = level.get('practice') ? false for level in @levels.models + completeSessions = @sessions.filter (s) -> s.get('state')?.complete and not levelPracticeMap[s.get('levelID')] stats.averageLevelsComplete = if @users.size() then (_.size(completeSessions) / @users.size()).toFixed(1) else 'N/A' # ' stats.totalLevelsComplete = _.size(completeSessions) diff --git a/app/views/courses/CourseDetailsView.coffee b/app/views/courses/CourseDetailsView.coffee index 61ebbf35c..8a659103d 100644 --- a/app/views/courses/CourseDetailsView.coffee +++ b/app/views/courses/CourseDetailsView.coffee @@ -52,7 +52,7 @@ module.exports = class CourseDetailsView extends RootView @supermodel.trackRequest(@classroom.fetch()) levelsLoaded = @supermodel.trackRequest(@levels.fetchForClassroomAndCourse(classroomID, @courseID, { - data: { project: 'concepts,type,slug,name,original,description' } + data: { project: 'concepts,practice,type,slug,name,original,description' } })) @supermodel.trackRequest($.when(levelsLoaded, sessionsLoaded).then(=> diff --git a/app/views/courses/TeacherClassView.coffee b/app/views/courses/TeacherClassView.coffee index fc821f0aa..53f3ce9ba 100644 --- a/app/views/courses/TeacherClassView.coffee +++ b/app/views/courses/TeacherClassView.coffee @@ -118,7 +118,7 @@ module.exports = class TeacherClassView extends RootView @supermodel.trackRequest @courseInstances.fetchForClassroom(classroomID) @levels = new Levels() - @supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}}) + @supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts,practice'}}) @attachMediatorEvents() window.tracker?.trackEvent 'Teachers Class Loaded', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] @@ -449,7 +449,9 @@ module.exports = class TeacherClassView extends RootView stats.totalPlaytime = if playtime then moment.duration(playtime, "seconds").humanize() else 0 # TODO: Humanize differently ('1 hour' instead of 'an hour') - completeSessions = @classroom.sessions.filter (s) -> s.get('state')?.complete + levelPracticeMap = {} + levelPracticeMap[level.id] = level.get('practice') ? false for level in @levels.models + completeSessions = @classroom.sessions.filter (s) -> s.get('state')?.complete and not levelPracticeMap[s.get('levelID')] stats.averageLevelsComplete = if @students.size() then (_.size(completeSessions) / @students.size()).toFixed(1) else 'N/A' # ' stats.totalLevelsComplete = _.size(completeSessions) diff --git a/app/views/play/level/ControlBarView.coffee b/app/views/play/level/ControlBarView.coffee index 32d3b7e2f..8254e9b18 100644 --- a/app/views/play/level/ControlBarView.coffee +++ b/app/views/play/level/ControlBarView.coffee @@ -2,6 +2,10 @@ CocoView = require 'views/core/CocoView' template = require 'templates/play/level/control_bar' {me} = require 'core/auth' +Campaign = require 'models/Campaign' +Classroom = require 'models/Classroom' +Course = require 'models/Course' +CourseInstance = require 'models/CourseInstance' GameMenuModal = require 'views/play/menu/GameMenuModal' RealTimeModel = require 'models/RealTimeModel' RealTimeCollection = require 'collections/RealTimeCollection' @@ -28,6 +32,7 @@ module.exports = class ControlBarView extends CocoView 'click #control-bar-sign-up-button': 'onClickSignupButton' constructor: (options) -> + @supermodel = options.supermodel @courseID = options.courseID @courseInstanceID = options.courseInstanceID @@ -38,6 +43,26 @@ module.exports = class ControlBarView extends CocoView @levelID = @levelSlug or @level.id @spectateGame = options.spectateGame ? false @observing = options.session.get('creator') isnt me.id + + @levelNumber = '' + if @level.get('type') is 'course' and @level.get('campaignIndex')? + @levelNumber = @level.get('campaignIndex') + 1 + if @courseInstanceID + @courseInstance = new CourseInstance(_id: @courseInstanceID) + jqxhr = @courseInstance.fetch() + @supermodel.trackRequest(jqxhr) + new Promise(jqxhr.then).then(=> + @classroom = new Classroom(_id: @courseInstance.get('classroomID')) + @supermodel.trackRequest @classroom.fetch() + ) + else if @courseID + @course = new Course(_id: @courseID) + jqxhr = @course.fetch() + @supermodel.trackRequest(jqxhr) + new Promise(jqxhr.then).then(=> + @campaign = new Campaign(_id: @course.get('campaignID')) + @supermodel.trackRequest(@campaign.fetch()) + ) super options if @level.get('type') in ['hero-ladder', 'course-ladder'] and me.isAdmin() @isMultiplayerLevel = true @@ -45,6 +70,13 @@ module.exports = class ControlBarView extends CocoView if @level.get 'replayable' @listenTo @session, 'change-difficulty', @onSessionDifficultyChanged + onLoaded: -> + if @classroom + @levelNumber = @classroom.getLevelNumber(@level.get('original'), @levelNumber) + else if @campaign + @levelNumber = @campaign.getLevelNumber(@level.get('original'), @levelNumber) + super() + setBus: (@bus) -> onPlayerStatesChanged: (e) -> @@ -62,7 +94,6 @@ module.exports = class ControlBarView extends CocoView getRenderData: (c={}) -> super c c.worldName = @worldName - c.campaignIndex = @level.get('campaignIndex') + 1 if @level.get('type') is 'course' and @level.get('campaignIndex')? # TODO: support 'game-dev' levels in courses c.multiplayerEnabled = @session.get('multiplayer') c.ladderGame = @level.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] if c.isMultiplayerLevel = @isMultiplayerLevel diff --git a/app/views/play/level/modal/CourseVictoryModal.coffee b/app/views/play/level/modal/CourseVictoryModal.coffee index 47ec3eb84..60c68d716 100644 --- a/app/views/play/level/modal/CourseVictoryModal.coffee +++ b/app/views/play/level/modal/CourseVictoryModal.coffee @@ -43,7 +43,12 @@ module.exports = class CourseVictoryModal extends ModalView @playSound 'victory' @nextLevel = new Level() - @nextLevelRequest = @supermodel.trackRequest @nextLevel.fetchNextForCourse({ levelOriginalID: @level.get('original'), @courseInstanceID, @courseID }) + @nextLevelRequest = @supermodel.trackRequest(@nextLevel.fetchNextForCourse({ + levelOriginalID: @level.get('original') + @courseInstanceID + @courseID + sessionID: @session.id + })) @course = options.course if @courseID and not @course diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee index e95437b9d..36649413e 100644 --- a/server/middleware/classrooms.coffee +++ b/server/middleware/classrooms.coffee @@ -151,12 +151,10 @@ module.exports = courseData = { _id: course._id, levels: [] } campaign = campaignMap[course.get('campaignID').toString()] levels = _.values(campaign.get('levels')) - # TODO: remove practice filter after classroom Ux supports practice levels - levels = _.reject(levels, {'practice': true}) levels = _.sortBy(levels, 'campaignIndex') for level in levels levelData = { original: mongoose.Types.ObjectId(level.original) } - _.extend(levelData, _.pick(level, 'type', 'slug', 'name')) + _.extend(levelData, _.pick(level, 'type', 'slug', 'name', 'practice', 'practiceThresholdMinutes')) courseData.levels.push(levelData) coursesData.push(courseData) classroom.set('courses', coursesData) diff --git a/server/middleware/course-instances.coffee b/server/middleware/course-instances.coffee index b7cee43f4..e64de3f48 100644 --- a/server/middleware/course-instances.coffee +++ b/server/middleware/course-instances.coffee @@ -9,8 +9,10 @@ Classroom = require '../models/Classroom' Course = require '../models/Course' User = require '../models/User' Level = require '../models/Level' +LevelSession = require '../models/LevelSession' parse = require '../commons/parse' {objectIdFromTimestamp} = require '../lib/utils' +utils = require '../../app/core/utils' Prepaid = require '../models/Prepaid' module.exports = @@ -21,93 +23,103 @@ module.exports = userIDs = req.body.userIDs else throw new errors.UnprocessableEntity('Must provide userID or userIDs') - + for userID in userIDs unless _.all userIDs, database.isID throw new errors.UnprocessableEntity('Invalid list of user IDs') - + courseInstance = yield database.getDocFromHandle(req, CourseInstance) if not courseInstance throw new errors.NotFound('Course Instance not found.') - + classroom = yield Classroom.findById courseInstance.get('classroomID') if not classroom throw new errors.NotFound('Classroom not found.') - + classroomMembers = (userID.toString() for userID in classroom.get('members')) unless _.all(userIDs, (userID) -> _.contains classroomMembers, userID) throw new errors.Forbidden('Users must be members of classroom') - + ownsClassroom = classroom.get('ownerID').equals(req.user._id) addingSelf = userIDs.length is 1 and userIDs[0] is req.user.id unless ownsClassroom or addingSelf throw new errors.Forbidden('You must own the classroom to add members') - + # Only the enrolled users users = yield User.find({ _id: { $in: userIDs }}).select('coursePrepaid coursePrepaidID') # TODO: remove coursePrepaidID once migrated usersAreEnrolled = _.all((user.isEnrolled() for user in users)) - + course = yield Course.findById courseInstance.get('courseID') throw new errors.NotFound('Course referenced by course instance not found') unless course - + if not (course.get('free') or usersAreEnrolled) throw new errors.PaymentRequired('Cannot add users to a course instance until they are added to a prepaid') - + userObjectIDs = (mongoose.Types.ObjectId(userID) for userID in userIDs) - + courseInstance = yield CourseInstance.findByIdAndUpdate( courseInstance._id, { $addToSet: { members: { $each: userObjectIDs } } } { new: true } ) - + userUpdateResult = yield User.update( { _id: { $in: userObjectIDs } }, { $addToSet: { courseInstances: courseInstance._id } } ) - + res.status(200).send(courseInstance.toObject({ req })) fetchNextLevel: wrap (req, res) -> levelOriginal = req.params.levelOriginal - if not database.isID(levelOriginal) - throw new errors.UnprocessableEntity('Invalid level original ObjectId') - + unless database.isID(levelOriginal) then throw new errors.UnprocessableEntity('Invalid level original ObjectId') + sessionID = req.params.sessionID + unless database.isID(sessionID) then throw new errors.UnprocessableEntity('Invalid session ObjectId') courseInstance = yield database.getDocFromHandle(req, CourseInstance) - if not courseInstance - throw new errors.NotFound('Course Instance not found.') - courseID = courseInstance.get('courseID') - + unless courseInstance then throw new errors.NotFound('Course Instance not found.') classroom = yield Classroom.findById courseInstance.get('classroomID') - if not classroom - throw new errors.NotFound('Classroom not found.') - - nextLevelOriginal = null - foundLevelOriginal = false - for course in classroom.get('courses') or [] - if not courseID.equals(course._id) - continue - for level, index in course.levels - if level.original.toString() is levelOriginal - foundLevelOriginal = true - nextLevelOriginal = course.levels[index+1]?.original - break - - if not foundLevelOriginal - throw new errors.NotFound('Level original ObjectId not found in Classroom courses') - - if not nextLevelOriginal - return res.status(200).send({}) - + unless classroom then throw new errors.NotFound('Classroom not found.') + currentLevel = yield Level.findOne({original: mongoose.Types.ObjectId(levelOriginal)}, {practiceThresholdMinutes: 1, type: 1}) + unless currentLevel then throw new errors.NotFound('Current level not found.') + + courseID = courseInstance.get('courseID') + courseLevels = [] + courseLevels = course.levels for course in classroom.get('courses') or [] when courseID.equals(course._id) + + # Get level completions and playtime + currentLevelSession = null + levelIDs = (level.original.toString() for level in courseLevels) + query = {$and: [{creator: req.user.id}, {'level.original': {$in: levelIDs}}]} + levelSessions = yield LevelSession.find(query, {level: 1, playtime: 1, state: 1}) + levelCompleteMap = {} + for levelSession in levelSessions + currentLevelSession = levelSession if levelSession.id is sessionID + levelCompleteMap[levelSession.get('level')?.original] = levelSession.get('state')?.complete + unless currentLevelSession then throw new errors.NotFound('Level session not found.') + needsPractice = utils.needsPractice(currentLevelSession.get('playtime'), currentLevel.get('practiceThresholdMinutes')) + + # Find next level + levels = [] + currentIndex = -1 + for level, index in courseLevels + currentIndex = index if level.original.toString() is levelOriginal + levels.push + practice: level.practice ? false + complete: levelCompleteMap[level.original?.toString()] or currentIndex is index + unless currentIndex >=0 then throw new errors.NotFound('Level original ObjectId not found in Classroom courses') + nextLevelIndex = utils.findNextLevel(levels, currentIndex, needsPractice) + nextLevelOriginal = courseLevels[nextLevelIndex]?.original + unless nextLevelOriginal then return res.status(200).send({}) + + # Return full Level object dbq = Level.findOne({original: mongoose.Types.ObjectId(nextLevelOriginal)}) dbq.sort({ 'version.major': -1, 'version.minor': -1 }) dbq.select(parse.getProjectFromReq(req)) level = yield dbq level = level.toObject({req: req}) res.status(200).send(level) - - + fetchClassroom: wrap (req, res) -> courseInstance = yield database.getDocFromHandle(req, CourseInstance) if not courseInstance @@ -126,25 +138,25 @@ module.exports = res.status(200).send(classroom) - + fetchRecent: wrap (req, res) -> query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]} query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay? query["$and"].push(_id: {$lt: objectIdFromTimestamp(req.body.endDay + "T00:00:00.000Z")}) if req.body.endDay? courseInstances = yield CourseInstance.find(query, {courseID: 1, members: 1, ownerID: 1}) - + userIDs = [] for courseInstance in courseInstances if members = courseInstance.get('members') userIDs.push(userID) for userID in members users = yield User.find({_id: {$in: userIDs}}, {coursePrepaid: 1, coursePrepaidID: 1}) - + prepaidIDs = [] for user in users if prepaidID = user.get('coursePrepaid') prepaidIDs.push(prepaidID._id) prepaids = yield Prepaid.find({_id: {$in: prepaidIDs}}, {properties: 1}) - + res.send({ courseInstances: (courseInstance.toObject({req: req}) for courseInstance in courseInstances) students: (user.toObject({req: req}) for user in users) diff --git a/server/middleware/levels.coffee b/server/middleware/levels.coffee index a7c93560c..fd39e6258 100644 --- a/server/middleware/levels.coffee +++ b/server/middleware/levels.coffee @@ -55,6 +55,7 @@ module.exports = classroomID = courseInstance.get('classroomID') continue unless classroomID classroom = classroomMap[classroomID.toString()] + continue unless classroom courseID = courseInstance.get('courseID') classroomCourse = _.find(classroom.get('courses'), (c) -> c._id.equals(courseID)) for courseLevel in classroomCourse.levels diff --git a/server/routes/index.coffee b/server/routes/index.coffee index cc4ed4bee..befd10c26 100644 --- a/server/routes/index.coffee +++ b/server/routes/index.coffee @@ -81,7 +81,7 @@ module.exports.setup = (app) -> app.get('/db/course/:handle/levels/:levelOriginal/next', mw.courses.fetchNextLevel) app.post('/db/course_instance/-/recent', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchRecent) - app.get('/db/course_instance/:handle/levels/:levelOriginal/next', mw.courseInstances.fetchNextLevel) + app.get('/db/course_instance/:handle/levels/:levelOriginal/sessions/:sessionID/next', mw.courseInstances.fetchNextLevel) app.post('/db/course_instance/:handle/members', mw.auth.checkLoggedIn(), mw.courseInstances.addMembers) app.get('/db/course_instance/:handle/classroom', mw.auth.checkLoggedIn(), mw.courseInstances.fetchClassroom) diff --git a/spec/server/functional/classrooms.spec.coffee b/spec/server/functional/classrooms.spec.coffee index ff643e16b..6c8ffe855 100644 --- a/spec/server/functional/classrooms.spec.coffee +++ b/spec/server/functional/classrooms.spec.coffee @@ -144,16 +144,6 @@ describe 'POST /db/classroom', -> expect(classroom.get('courses')[0].levels[0].name).toBe('Level A') done() - it 'makes a copy of the list of all non-practice levels in all courses', utils.wrap (done) -> - teacher = yield utils.initUser({role: 'teacher'}) - yield utils.loginUser(teacher) - data = { name: 'tmp Classroom 2' } - [res, body] = yield request.postAsync {uri: classroomsURL, json: data } - classroom = yield Classroom.findById(res.body._id) - # console.log(JSON.stringify(classroom.get('courses')[0], null, 2)); - expect(classroom.get('courses')[0].levels.length).toEqual(2) - done() - describe 'GET /db/classroom/:handle/levels', -> beforeEach utils.wrap (done) -> diff --git a/spec/server/functional/course_instance.spec.coffee b/spec/server/functional/course_instance.spec.coffee index da89aa2b1..5c00cec82 100644 --- a/spec/server/functional/course_instance.spec.coffee +++ b/spec/server/functional/course_instance.spec.coffee @@ -9,6 +9,7 @@ User = require '../../../server/models/User' Classroom = require '../../../server/models/Classroom' Campaign = require '../../../server/models/Campaign' Level = require '../../../server/models/Level' +LevelSession = require '../../../server/models/LevelSession' Prepaid = require '../../../server/models/Prepaid' request = require '../request' moment = require 'moment' @@ -48,7 +49,7 @@ describe 'POST /db/course_instance', -> expect(res.statusCode).toBe(200) expect(body.classroomID).toBeDefined() done() - + it 'returns the same CourseInstance if you POST twice', utils.wrap (done) -> data = { name: 'Some Name' @@ -167,7 +168,7 @@ describe 'POST /db/course_instance/:id/members', -> [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} expect(res.statusCode).toBe(402) done() - + it 'works if the course is not free and the user is enrolled', utils.wrap (done) -> @course.set('free', false) yield @course.save() @@ -187,12 +188,12 @@ describe 'POST /db/course_instance/:id/members', -> [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} expect(res.statusCode).toBe(200) done() - + describe 'DELETE /db/course_instance/:id/members', -> beforeEach utils.wrap (done) -> utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid]) - + # create, login user @teacher = yield utils.initUser({role: 'teacher'}) yield utils.loginUser(@teacher) @@ -211,7 +212,7 @@ describe 'DELETE /db/course_instance/:id/members', -> } [res, body] = yield request.postAsync {uri: url, json: data} @courseInstance = yield CourseInstance.findById res.body._id - + # add user to course instance url = getURL("/db/course_instance/#{@courseInstance.id}/members") [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} @@ -221,14 +222,14 @@ describe 'DELETE /db/course_instance/:id/members', -> redeemers: [] }).save() done() - + it 'removes a member to the given CourseInstance', utils.wrap (done) -> url = getURL("/db/course_instance/#{@courseInstance.id}/members") [res, body] = yield request.delAsync {uri: url, json: {userID: @student.id}} expect(res.statusCode).toBe(200) expect(res.body.members.length).toBe(0) done() - + it 'removes the CourseInstance from the User.courseInstances', utils.wrap (done) -> url = getURL("/db/course_instance/#{@courseInstance.id}/members") user = yield User.findById(@student.id) @@ -239,13 +240,14 @@ describe 'DELETE /db/course_instance/:id/members', -> user = yield User.findById(@student.id) expect(_.size(user.get('courseInstances'))).toBe(0) done() - + describe 'GET /db/course_instance/:handle/levels/:levelOriginal/next', -> beforeEach utils.wrap (done) -> yield utils.clearModels [User, Classroom, Course, Level, Campaign] admin = yield utils.initAdmin() yield utils.loginUser(admin) + teacher = yield utils.initUser({role: 'teacher'}) levelJSON = { name: 'A', permissions: [{access: 'owner', target: admin.id}], type: 'course' } [res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSON}) @@ -253,12 +255,25 @@ describe 'GET /db/course_instance/:handle/levels/:levelOriginal/next', -> @levelA = yield Level.findById(res.body._id) paredLevelA = _.pick(res.body, 'name', 'original', 'type') + @sessionA = new LevelSession + creator: teacher.id + level: original: @levelA.get('original').toString() + permissions: simplePermissions + state: complete: true + yield @sessionA.save() + levelJSON = { name: 'B', permissions: [{access: 'owner', target: admin.id}], type: 'course' } [res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSON}) expect(res.statusCode).toBe(200) @levelB = yield Level.findById(res.body._id) paredLevelB = _.pick(res.body, 'name', 'original', 'type') + @sessionB = new LevelSession + creator: teacher.id + level: original: @levelB.get('original').toString() + permissions: simplePermissions + yield @sessionB.save() + levelJSON = { name: 'C', permissions: [{access: 'owner', target: admin.id}], type: 'course' } [res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSON}) expect(res.statusCode).toBe(200) @@ -282,14 +297,13 @@ describe 'GET /db/course_instance/:handle/levels/:levelOriginal/next', -> @courseB = Course({name: 'Course B', campaignID: @campaignB._id}) yield @courseB.save() - teacher = yield utils.initUser({role: 'teacher'}) yield utils.loginUser(teacher) data = { name: 'Classroom 1' } classroomsURL = getURL('/db/classroom') [res, body] = yield request.postAsync {uri: classroomsURL, json: data } expect(res.statusCode).toBe(201) @classroom = yield Classroom.findById(res.body._id) - + url = getURL('/db/course_instance') dataA = { name: 'Some Name', courseID: @courseA.id, classroomID: @classroom.id } @@ -301,27 +315,27 @@ describe 'GET /db/course_instance/:handle/levels/:levelOriginal/next', -> [res, body] = yield request.postAsync {uri: url, json: dataB} expect(res.statusCode).toBe(200) @courseInstanceB = yield CourseInstance.findById(res.body._id) - + done() it 'returns the next level for the course in the linked classroom', utils.wrap (done) -> - [res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceA.id}/levels/#{@levelA.id}/next"), json: true } + [res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceA.id}/levels/#{@levelA.id}/sessions/#{@sessionA.id}/next"), json: true } expect(res.statusCode).toBe(200) expect(res.body.original).toBe(@levelB.original.toString()) done() - + it 'returns empty object if the given level is the last level in its course', utils.wrap (done) -> - [res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceA.id}/levels/#{@levelB.id}/next"), json: true } + [res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceA.id}/levels/#{@levelB.id}/sessions/#{@sessionB.id}/next"), json: true } expect(res.statusCode).toBe(200) expect(res.body).toEqual({}) done() it 'returns 404 if the given level is not in the course instance\'s course', utils.wrap (done) -> - [res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceB.id}/levels/#{@levelA.id}/next"), json: true } + [res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceB.id}/levels/#{@levelA.id}/sessions/#{@sessionA.id}/next"), json: true } expect(res.statusCode).toBe(404) done() - - + + describe 'GET /db/course_instance/:handle/classroom', -> beforeEach utils.wrap (done) -> @@ -362,9 +376,9 @@ describe 'GET /db/course_instance/:handle/classroom', -> done() describe 'POST /db/course_instance/-/recent', -> - + url = getURL('/db/course_instance/-/recent') - + beforeEach utils.wrap (done) -> yield utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid, Campaign, Level]) @teacher = yield utils.initUser({role: 'teacher'}) @@ -405,9 +419,9 @@ describe 'POST /db/course_instance/-/recent', -> endDay = moment().subtract(1, 'day').format('YYYY-MM-DD') [res, body] = yield request.postAsync(url, { json: { startDay, endDay } }) expect(res.body.courseInstances.length).toBe(0) - + done() - + it 'returns 403 if not an admin', utils.wrap (done) -> yield utils.loginUser(@teacher) [res, body] = yield request.postAsync(url, { json: true }) diff --git a/test/app/core/utils.spec.coffee b/test/app/core/utils.spec.coffee index 048744eb4..9925391e1 100644 --- a/test/app/core/utils.spec.coffee +++ b/test/app/core/utils.spec.coffee @@ -1,5 +1,5 @@ describe 'Utility library', -> - util = require 'core/utils' + utils = require 'core/utils' describe 'i18n', -> beforeEach -> @@ -25,24 +25,217 @@ describe 'Utility library', -> 'text': 'Godagens, trollkarl! Kommit för att öva? Nå, låt oss börja...' it 'i18n should find a valid target string', -> - expect(util.i18n(this.fixture1, 'text', 'sv')).toEqual(this.fixture1.i18n['sv'].text) - expect(util.i18n(this.fixture1, 'text', 'es-ES')).toEqual(this.fixture1.i18n['es-ES'].text) + expect(utils.i18n(this.fixture1, 'text', 'sv')).toEqual(this.fixture1.i18n['sv'].text) + expect(utils.i18n(this.fixture1, 'text', 'es-ES')).toEqual(this.fixture1.i18n['es-ES'].text) it 'i18n picks the correct fallback for a specific language', -> - expect(util.i18n(this.fixture1, 'text', 'fr-be')).toEqual(this.fixture1.i18n['fr'].text) + expect(utils.i18n(this.fixture1, 'text', 'fr-be')).toEqual(this.fixture1.i18n['fr'].text) it 'i18n picks the correct fallback', -> - expect(util.i18n(this.fixture1, 'text', 'nl')).toEqual(this.fixture1.i18n['en'].text) - expect(util.i18n(this.fixture1, 'text', 'nl', 'de')).toEqual(this.fixture1.i18n['de'].text) + expect(utils.i18n(this.fixture1, 'text', 'nl')).toEqual(this.fixture1.i18n['en'].text) + expect(utils.i18n(this.fixture1, 'text', 'nl', 'de')).toEqual(this.fixture1.i18n['de'].text) it 'i18n falls back to the default text, even for other targets (like blurb)', -> delete this.fixture1.i18n['en'] - expect(util.i18n(this.fixture1, 'text', 'en')).toEqual(this.fixture1.text) - expect(util.i18n(this.fixture1, 'blurb', 'en')).toEqual(this.fixture1.blurb) + expect(utils.i18n(this.fixture1, 'text', 'en')).toEqual(this.fixture1.text) + expect(utils.i18n(this.fixture1, 'blurb', 'en')).toEqual(this.fixture1.blurb) delete this.fixture1.blurb - expect(util.i18n(this.fixture1, 'blurb', 'en')).toEqual(null) + expect(utils.i18n(this.fixture1, 'blurb', 'en')).toEqual(null) it 'i18n can fall forward if a general language is not found', -> - expect(util.i18n(this.fixture1, 'text', 'pt')).toEqual(this.fixture1.i18n['pt-BR'].text) + expect(utils.i18n(this.fixture1, 'text', 'pt')).toEqual(this.fixture1.i18n['pt-BR'].text) - describe 'Miscellaneous utility', -> + describe 'createLevelNumberMap', -> + it 'returns correct map for r', -> + levels = [ + {key: 1, practice: false} + ] + levelNumberMap = utils.createLevelNumberMap(levels) + expect((val.toString() for key, val of levelNumberMap)).toEqual(['1']) + it 'returns correct map for r r', -> + levels = [ + {key: 1, practice: false} + {key: 2, practice: false} + ] + levelNumberMap = utils.createLevelNumberMap(levels) + expect((val.toString() for key, val of levelNumberMap)).toEqual(['1', '2']) + it 'returns correct map for p', -> + levels = [ + {key: 1, practice: true} + ] + levelNumberMap = utils.createLevelNumberMap(levels) + expect((val.toString() for key, val of levelNumberMap)).toEqual(['0a']) + it 'returns correct map for r p r', -> + levels = [ + {key: 1, practice: false} + {key: 2, practice: true} + {key: 3, practice: false} + ] + levelNumberMap = utils.createLevelNumberMap(levels) + expect((val.toString() for key, val of levelNumberMap)).toEqual(['1', '1a', '2']) + it 'returns correct map for r p p p', -> + levels = [ + {key: 1, practice: false} + {key: 2, practice: true} + {key: 3, practice: true} + {key: 4, practice: true} + ] + levelNumberMap = utils.createLevelNumberMap(levels) + expect((val.toString() for key, val of levelNumberMap)).toEqual(['1', '1a', '1b', '1c']) + it 'returns correct map for r p p p r p p r r p r', -> + levels = [ + {key: 1, practice: false} + {key: 2, practice: true} + {key: 3, practice: true} + {key: 4, practice: true} + {key: 5, practice: false} + {key: 6, practice: true} + {key: 7, practice: true} + {key: 8, practice: false} + {key: 9, practice: false} + {key: 10, practice: true} + {key: 11, practice: false} + ] + levelNumberMap = utils.createLevelNumberMap(levels) + expect((val.toString() for key, val of levelNumberMap)).toEqual(['1', '1a', '1b', '1c', '2', '2a', '2b', '3', '4', '4a', '5']) + + describe 'findNextlevel', -> + describe 'when no practice needed', -> + needsPractice = false + it 'returns next level when rc* p', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: false} + ] + expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(2) + done() + it 'returns next level when pc* p r', (done) -> + levels = [ + {practice: true, complete: true} + {practice: true, complete: false} + {practice: false, complete: false} + ] + expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(2) + done() + it 'returns next level when pc* p p', (done) -> + levels = [ + {practice: true, complete: true} + {practice: true, complete: false} + {practice: true, complete: false} + ] + expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(3) + done() + it 'returns next level when rc* p rc', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: false} + {practice: false, complete: true} + ] + expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(3) + done() + describe 'when needs practice', -> + needsPractice = true + it 'returns next level when rc* p', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: false} + ] + expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(1) + done() + it 'returns next level when rc* rc', (done) -> + levels = [ + {practice: false, complete: true} + {practice: false, complete: true} + ] + expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(2) + done() + it 'returns next level when rc p rc*', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: false} + {practice: false, complete: true} + ] + expect(utils.findNextLevel(levels, 2, needsPractice)).toEqual(1) + done() + it 'returns next level when rc pc p rc*', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: true} + {practice: true, complete: false} + {practice: false, complete: true} + ] + expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(2) + done() + it 'returns next level when rc pc p rc* p', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: true} + {practice: true, complete: false} + {practice: false, complete: true} + {practice: true, complete: false} + ] + expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(4) + done() + it 'returns next level when rc pc p rc* pc', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: true} + {practice: true, complete: false} + {practice: false, complete: true} + {practice: true, complete: true} + ] + expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(5) + done() + it 'returns next level when rc pc p rc* pc p', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: true} + {practice: true, complete: false} + {practice: false, complete: true} + {practice: true, complete: true} + {practice: true, complete: false} + ] + expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(5) + done() + it 'returns next level when rc pc p rc* pc r', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: true} + {practice: true, complete: false} + {practice: false, complete: true} + {practice: true, complete: true} + {practice: false, complete: false} + ] + expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(5) + done() + it 'returns next level when rc pc p rc* pc p r', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: true} + {practice: true, complete: false} + {practice: false, complete: true} + {practice: true, complete: true} + {practice: true, complete: false} + {practice: false, complete: false} + ] + expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(5) + done() + it 'returns next level when rc pc pc rc* r p', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: true} + {practice: true, complete: true} + {practice: false, complete: true} + {practice: false, complete: false} + {practice: true, complete: false} + ] + expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(4) + done() + it 'returns next level when rc* pc rc', (done) -> + levels = [ + {practice: false, complete: true} + {practice: true, complete: true} + {practice: false, complete: true} + ] + expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(3) + done()