diff --git a/app/schemas/models/mail_sent.coffee b/app/schemas/models/mail_sent.coffee new file mode 100644 index 000000000..4cd54e241 --- /dev/null +++ b/app/schemas/models/mail_sent.coffee @@ -0,0 +1,17 @@ +c = require './../schemas' +#This will represent transactional emails which have been sent + +MailSentSchema = c.object { + title: 'Sent mail' + description: 'Emails which have been sent through the system' +} +_.extend MailSentSchema.properties, + mailTask: c.objectId {} + user: c.objectId links: [{rel: 'extra', href: '/db/user/{($)}'}] + sent: c.date title: 'Sent', readOnly: true + metadata: c.object {}, {} + +c.extendBasicProperties MailSentSchema, 'mail.sent' + +module.exports = MailSentSchema + \ No newline at end of file diff --git a/app/templates/docs/components.jade b/app/templates/docs/components.jade index ac92772f6..157bb7f39 100644 --- a/app/templates/docs/components.jade +++ b/app/templates/docs/components.jade @@ -26,5 +26,8 @@ block content li(id="#{component.get('name')}#{doc.name}") | #{doc.name} ul.specialList - li!=marked(doc.description) - \ No newline at end of file + if doc.description[language.substring(1,language.length-1)] + li!=marked(doc.description[language.substring(1,language.length-1)]) + else + li!=marked(doc.description) + diff --git a/app/views/docs/components_view.coffee b/app/views/docs/components_view.coffee index 0d4f9d5e2..181eacbbd 100644 --- a/app/views/docs/components_view.coffee +++ b/app/views/docs/components_view.coffee @@ -19,10 +19,20 @@ module.exports = class UnnamedView extends RootView onLoaded: -> console.log 'we have the components...', (c.get('name') for c in @componentDocs.models) console.log 'we have the attributes...', (c.attributes for c in @componentDocs.models) + if (me.get('aceConfig')?.language?) is false + console.log 'default language javascript' + else + console.log 'language is =', me.get('aceConfig').language + + #console.log 'test', @componentDocs.models[99].attributes.propertyDocumentation[1].description['python'] super() getRenderData: -> c = super() c.components = @componentDocs.models c.marked = marked + if (me.get('aceConfig')?.language?) is false + c.language = 'javascript' + else + c.language = JSON.stringify(me.get('aceConfig').language) c diff --git a/package.json b/package.json index cb317a6f4..27d76ce3b 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "express-useragent": "~0.0.9", "gridfs-stream": "0.4.x", "stream-buffers": "0.2.x", - "sendwithus": "2.0.x", + "sendwithus": "2.1.x", "aws-sdk": "~2.0.0", "bayesian-battle": "0.0.x", "redis": "", diff --git a/server/commons/LockManager.coffee b/server/commons/LockManager.coffee new file mode 100644 index 000000000..36936b236 --- /dev/null +++ b/server/commons/LockManager.coffee @@ -0,0 +1,43 @@ +config = require '../../server_config' +redis = require 'redis' +log = require 'winston' + +class LockManager + constructor: -> + unless config.isProduction + throw "You shouldn't be instantiating distributed locks unless in production." + @redisNotAvailable = true + @redisClient = redis.createClient config.redis.port, config.redis.host + @redisClient.on "ready", => + log.info "Redis ready!" + @redisNotAvailable = false + @redisClient.on "error", (err) => + @redisNotAvailable = true + log.error "Redis connection error! Err: #{err}" + @redisClient.on "end", => + @redisNotAvailable = true + log.error "Redis connection ended!" + @lockValues = {} + @unlockScript = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then return redis.call(\"del\",KEYS[1]) else return 0 end" + + setLock: (lockName, timeoutMs, cb) => + if @redisNotAvailable is true then return cb "Redis not available!" + randomNumber = Math.floor(Math.random() * 1000000000) + @redisClient.set [lockName,randomNumber, "NX", "PX", timeoutMs], (err, res) => + if err? then return cb err, null + if res is "OK" + @lockValues[lockName] = randomNumber + return cb null, "Lock set!" + unless res + return cb "Lock already set!", null + + releaseLock: (lockName, cb) => + if @redisNotAvailable is true then return cb "Redis not available!" + @redisClient.eval [@unlockScript, 1, lockName, @lockValues[lockName]], (err, res) -> + if err? then return cb err, null + if res + cb null, "The lock was released!" + else + cb "The lock was not released.", null + +module.exports = new LockManager() diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee index 802b33790..69f8abfc0 100644 --- a/server/commons/mapping.coffee +++ b/server/commons/mapping.coffee @@ -9,6 +9,7 @@ module.exports.handlers = 'thang_type': 'levels/thangs/thang_type_handler' 'user': 'users/user_handler' 'user_remark': 'users/remarks/user_remark_handler' + 'mail_sent': 'mail/sent/mail_sent_handler' 'achievement': 'achievements/achievement_handler' 'earned_achievement': 'achievements/earned_achievement_handler' diff --git a/server/mail/sent/MailSent.coffee b/server/mail/sent/MailSent.coffee new file mode 100644 index 000000000..f8479033a --- /dev/null +++ b/server/mail/sent/MailSent.coffee @@ -0,0 +1,11 @@ +mongoose = require 'mongoose' +plugins = require '../../plugins/plugins' +jsonschema = require '../../../app/schemas/models/mail_sent' + +MailSent = new mongoose.Schema({ + sent: + type: Date + 'default': Date.now +}, {strict: false}) + +module.exports = MailSent = mongoose.model('mail.sent', MailSent) diff --git a/server/mail/sent/mail_sent_handler.coffee b/server/mail/sent/mail_sent_handler.coffee new file mode 100644 index 000000000..35e920c2b --- /dev/null +++ b/server/mail/sent/mail_sent_handler.coffee @@ -0,0 +1,12 @@ +MailSent = require './MailSent' +Handler = require '../../commons/Handler' + +class MailSentHandler extends Handler + modelClass: MailSent + editableProperties: ['mailTask','user','sent'] + jsonSchema: require '../../../app/schemas/models/mail_sent' + + hasAccess: (req) -> + req.user?.isAdmin() + +module.exports = new MailSentHandler() diff --git a/server/routes/auth.coffee b/server/routes/auth.coffee index 4b038c15e..5bb1cf1a2 100644 --- a/server/routes/auth.coffee +++ b/server/routes/auth.coffee @@ -117,7 +117,8 @@ module.exports.setup = (app) -> ) ) - app.get '/auth/unsubscribe', (req, res) -> + app.get '/auth/unsubscribe', (req, res) -> + req.query.email = decodeURIComponent(req.query.email) email = req.query.email unless req.query.email return errors.badInput res, 'No email provided to unsubscribe.' @@ -131,7 +132,7 @@ module.exports.setup = (app) -> return errors.serverError res, 'Database failure.' if err res.send "Unsubscribed #{req.query.email} from CodeCombat emails for #{session.levelName} #{session.team} ladder updates. Sorry to see you go!
" res.end() - + User.findOne({emailLower: req.query.email.toLowerCase()}).exec (err, user) -> if not user return errors.notFound res, "No user found with email '#{req.query.email}'" @@ -143,7 +144,11 @@ module.exports.setup = (app) -> emails.recruitNotes ?= {} emails.recruitNotes.enabled = false msg = "Unsubscribed #{req.query.email} from recruiting emails." - + else if req.query.employerNotes + emails.employerNotes ?= {} + emails.employerNotes.enabled = false + + msg = "Unsubscribed #{req.query.email} from employer emails." else msg = "Unsubscribed #{req.query.email} from all CodeCombat emails. Sorry to see you go!" emailSettings.enabled = false for emailSettings in _.values(emails) diff --git a/server/routes/mail.coffee b/server/routes/mail.coffee index 4fdcef128..0f648178e 100644 --- a/server/routes/mail.coffee +++ b/server/routes/mail.coffee @@ -1,17 +1,330 @@ mail = require '../commons/mail' +MailSent = require '../mail/sent/MailSent' User = require '../users/User' +async = require 'async' errors = require '../commons/errors' config = require '../../server_config' LevelSession = require '../levels/sessions/LevelSession' Level = require '../levels/Level' log = require 'winston' sendwithus = require '../sendwithus' - - +if config.isProduction + lockManager = require '../commons/LockManager' + module.exports.setup = (app) -> app.all config.mail.mailchimpWebhook, handleMailchimpWebHook app.get '/mail/cron/ladder-update', handleLadderUpdate + if lockManager + setupScheduledEmails() +setupScheduledEmails = -> + testForLockManager() + mailTasks = [ + taskFunction: candidateUpdateProfileTask + frequencyMs: 10 * 60 * 1000 #10 minutes + , + taskFunction: internalCandidateUpdateTask + frequencyMs: 10 * 60 * 1000 #10 minutes + , + taskFunction: employerNewCandidatesAvailableTask + frequencyMs: 10 * 60 * 1000 #10 minutes + ] + + for mailTask in mailTasks + setInterval mailTask.taskFunction, mailTask.frequencyMs + +testForLockManager = -> unless lockManager then throw "The system isn't configured to do distributed locking!" + +### Candidate Update Reminder Task ### + +candidateUpdateProfileTask = -> + mailTaskName = "candidateUpdateProfileTask" + lockDurationMs = 2 * 60 * 1000 + currentDate = new Date() + timeRanges = [] + for weekPair in [[4, 2,'two weeks'], [8, 4, 'four weeks'], [8, 52, 'eight weeks']] + timeRanges.push + start: generateWeekOffset currentDate, weekPair[0] + end: generateWeekOffset currentDate, weekPair[1] + name: weekPair[2] + lockManager.setLock mailTaskName, lockDurationMs, (err) -> + if err? then return log.error "Error getting a distributed lock for task #{mailTaskName}: #{err}" + async.each timeRanges, emailTimeRange.bind({mailTaskName: mailTaskName}), (err) -> + if err + log.error "There was an error sending the candidate profile update reminder emails: #{err}" + else + log.info "Completed mail task #{mailTaskName}" + lockManager.releaseLock mailTaskName, (err) -> + if err? then return log.error "There was an error releasing the distributed lock for task #{mailTaskName}: #{err}" + +generateWeekOffset = (originalDate, numberOfWeeks) -> + return (new Date(originalDate.getTime() - numberOfWeeks * 7 * 24 * 60 * 60 * 1000)).toISOString() + +emailTimeRange = (timeRange, emailTimeRangeCallback) -> + waterfallContext = + "timeRange": timeRange + "mailTaskName": @mailTaskName + async.waterfall [ + findAllCandidatesWithinTimeRange.bind(waterfallContext) + (unfilteredCandidates, cb) -> + async.reject unfilteredCandidates, candidateFilter.bind(waterfallContext), cb.bind(null, null) + (filteredCandidates, cb) -> + async.each filteredCandidates, sendReminderEmailToCandidate.bind(waterfallContext), cb + ], emailTimeRangeCallback + +findAllCandidatesWithinTimeRange = (cb) -> + findParameters = + "jobProfile.updated": + $gt: @timeRange.start + $lte: @timeRange.end + "jobProfileApproved": true + selection = "_id email jobProfile.name jobProfile.updated emails" #make sure to check for anyNotes too. + User.find(findParameters).select(selection).lean().exec cb + +candidateFilter = (candidate, sentEmailFilterCallback) -> + if candidate.emails?.anyNotes?.enabled is false or candidate.emails?.recruitNotes?.enabled is false + return sentEmailFilterCallback true + findParameters = + "user": candidate._id + "mailTask": @mailTaskName + "metadata.timeRangeName": @timeRange.name + "metadata.updated": candidate.jobProfile.updated + MailSent.find(findParameters).lean().exec (err, sentMail) -> + if err? + log.error "Error finding mail sent for task #{@mailTaskName} and user #{candidate._id}!" + sentEmailFilterCallback true + else + sentEmailFilterCallback Boolean(sentMail.length) + +findEmployersSignedUpAfterDate = (dateObject, cb) -> + countParameters = + $or: [{"dateCreated": {$gte: dateObject}},{"signedEmployerAgreement":{$gte: dateObject}}] + employerAt: {$exists: true} + permissions: "employer" + User.count countParameters, cb + +sendReminderEmailToCandidate = (candidate, sendEmailCallback) -> + findEmployersSignedUpAfterDate new Date(candidate.jobProfile.updated), (err, employersAfterCount) => + if err? + log.error "There was an error finding employers who signed up after #{candidate.jobProfile.updated}: #{err}" + return sendEmailCallback err + if employersAfterCount < 2 + employersAfterCount = 2 + context = + email_id: "tem_CtTLsKQufxrxoPMn7upKiL" + recipient: + address: candidate.email + name: candidate.jobProfile.name + email_data: + new_company: employersAfterCount + company_name: "CodeCombat" + user_profile: "http://codecombat.com/account/profile/#{candidate._id}" + recipient_address: encodeURIComponent(candidate.email) + log.info "Sending #{@timeRange.name} update reminder to #{context.recipient.name}(#{context.recipient.address})" + newSentMail = + mailTask: @mailTaskName + user: candidate._id + metadata: + timeRangeName: @timeRange.name + updated: candidate.jobProfile.updated + MailSent.create newSentMail, (err) -> + if err? then return sendEmailCallback err + sendwithus.api.send context, (err, result) -> + log.error "Error sending candidate update reminder email: #{err} with result #{result}" if err + sendEmailCallback null +### End Candidate Update Reminder Task ### +### Internal Candidate Update Reminder Email ### +internalCandidateUpdateTask = -> + mailTaskName = "internalCandidateUpdateTask" + lockDurationMs = 2 * 60 * 1000 + lockManager.setLock mailTaskName, lockDurationMs, (err) -> + if err? then return log.error "Error getting a distributed lock for task #{mailTaskName}: #{err}" + emailInternalCandidateUpdateReminder.call {"mailTaskName":mailTaskName}, (err) -> + if err + log.error "There was an error sending the internal candidate update reminder.: #{err}" + else + log.info "Sent internal candidate update reminder email!" + lockManager.releaseLock mailTaskName, (err) -> + if err? then return log.error "There was an error releasing the distributed lock for task #{mailTaskName}: #{err}" + +emailInternalCandidateUpdateReminder = (internalCandidateUpdateReminderCallback) -> + currentTime = new Date() + beginningOfUTCDay = new Date() + beginningOfUTCDay.setUTCHours(0,0,0,0) + asyncContext = + "beginningOfUTCDay": beginningOfUTCDay + "currentTime": currentTime + "mailTaskName": @mailTaskName + async.waterfall [ + findNonApprovedCandidatesWhoUpdatedJobProfileToday.bind(asyncContext) + (unfilteredCandidates, cb) -> + async.reject unfilteredCandidates, candidatesUpdatedTodayFilter.bind(asyncContext), cb.bind(null,null) + (filteredCandidates, cb) -> + async.each filteredCandidates, sendInternalCandidateUpdateReminder.bind(asyncContext), cb + ], internalCandidateUpdateReminderCallback + +findNonApprovedCandidatesWhoUpdatedJobProfileToday = (cb) -> + findParameters = + "jobProfile.updated": + $lte: @currentTime.toISOString() + $gt: @beginningOfUTCDay.toISOString() + "jobProfileApproved": false + User.find(findParameters).select("_id jobProfile.name jobProfile.updated").lean().exec cb + +candidatesUpdatedTodayFilter = (candidate, cb) -> + findParameters = + "user": candidate._id + "mailTask": @mailTaskName + "metadata.beginningOfUTCDay": @beginningOfUTCDay + MailSent.find(findParameters).lean().exec (err, sentMail) -> + if err? + log.error "Error finding mail sent for task #{@mailTaskName} and user #{candidate._id}!" + cb true + else + cb Boolean(sentMail.length) + +sendInternalCandidateUpdateReminder = (candidate, cb) -> + context = + email_id: "tem_Ac7nhgKqatTHBCgDgjF5pE" + recipient: + address: "team@codecombat.com" + name: "The CodeCombat Team" + email_data: + new_candidate_profile: "https://codecombat.com/account/profile/#{candidate._id}" + log.info "Sending candidate updated reminder for #{candidate.jobProfile.name}" + newSentMail = + mailTask: @mailTaskName + user: candidate._id + metadata: + beginningOfUTCDay: @beginningOfUTCDay + + MailSent.create newSentMail, (err) -> + if err? then return cb err + sendwithus.api.send context, (err, result) -> + log.error "Error sending interal candidate update email: #{err} with result #{result}" if err + cb null + +### End Internal Candidate Update Reminder Email ### +### Employer New Candidates Available Email ### +employerNewCandidatesAvailableTask = -> + mailTaskName = "employerNewCandidatesAvailableTask" + lockDurationMs = 2 * 60 * 1000 + lockManager.setLock mailTaskName, lockDurationMs, (err) -> + if err? then return log.error "Error getting a distributed lock for task #{mailTaskName}: #{err}" + emailEmployerNewCandidatesAvailable.call {"mailTaskName":mailTaskName}, (err) -> + if err + log.error "There was an error completing the new candidates available task: #{err}" + else + log.info "Completed the employer new candidates available task!" + lockManager.releaseLock mailTaskName, (err) -> + if err? then return log.error "There was an error releasing the distributed lock for task #{mailTaskName}: #{err}" + +emailEmployerNewCandidatesAvailable = (emailEmployerNewCandidatesAvailableCallback) -> + currentTime = new Date() + asyncContext = + "currentTime": currentTime + "mailTaskName": @mailTaskName + + async.waterfall [ + findAllEmployers + makeEmployerNamesEasilyAccessible + (allEmployers, cb) -> + async.reject allEmployers, employersEmailedDigestMoreThanWeekAgoFilter.bind(asyncContext), cb.bind(null,null) + (employersToEmail, cb) -> + async.each employersToEmail, sendEmployerNewCandidatesAvailableEmail.bind(asyncContext), cb + ], emailEmployerNewCandidatesAvailableCallback + +findAllEmployers = (cb) -> + findParameters = + "employerAt": + $exists: true + permissions: "employer" + selection = "_id email employerAt signedEmployerAgreement.data.firstName signedEmployerAgreement.data.lastName activity dateCreated emails" + User.find(findParameters).select(selection).lean().exec cb + +makeEmployerNamesEasilyAccessible = (allEmployers, cb) -> + for employer, index in allEmployers + if employer.signedEmployerAgreement?.data?.firstName + employer.name = employer.signedEmployerAgreement.data.firstName + " " + employer.signedEmployerAgreement.data.lastName + delete employer.signedEmployerAgreement + allEmployers[index] = employer + cb null, allEmployers + +employersEmailedDigestMoreThanWeekAgoFilter = (employer, cb) -> + if employer.emails?.employerNotes?.enabled is false + return sentEmailFilterCallback true + findParameters = + "user": employer._id + "mailTask": @mailTaskName + "sent": + $gt: new Date(@currentTime.getTime() - 7 * 24 * 60 * 60 * 1000) + MailSent.find(findParameters).lean().exec (err, sentMail) -> + if err? + log.error "Error finding mail sent for task #{@mailTaskName} and employer #employer._id}!" + cb true + else + cb Boolean(sentMail.length) + +sendEmployerNewCandidatesAvailableEmail = (employer, cb) -> + lastLoginDate = employer.activity?.login?.last ? employer.dateCreated + countParameters = + "jobProfileApproved": true + $or: [ + jobProfileApprovedDate: + $gt: lastLoginDate.toISOString() + , + jobProfileApprovedDate: + $exists: false + "jobProfile.updated": + $gt: lastLoginDate.toISOString() + ] + User.count countParameters, (err, numberOfCandidatesSinceLogin) => + if err? then return cb err + context = + email_id: "tem_CCcHKr95Nvu5bT7c7iHCtm" + recipient: + address: employer.email + email_data: + new_candidates: numberOfCandidatesSinceLogin + employer_company_name: employer.employerAt + company_name: "CodeCombat" + recipient_address: encodeURIComponent(employer.email) + if employer.name + context.recipient.name = employer.name + log.info "Sending available candidates update reminder to #{context.recipient.name}(#{context.recipient.address})" + newSentMail = + mailTask: @mailTaskName + user: employer._id + MailSent.create newSentMail, (err) -> + if err? then return cb err + sendwithus.api.send context, (err, result) -> + log.error "Error sending employer candidates available email: #{err} with result #{result}" if err + cb null + +### End Employer New Candidates Available Email ### + +### New Recruit Leaderboard Email ### +### +newRecruitLeaderboardEmailTask = -> + # tem_kMQFCKX3v4DNAQDsMAsPJC + #maxRank and maxRankTime should be recorded if isSimulating is false + mailTaskName = "newRecruitLeaderboardEmailTask" + lockDurationMs = 6000 + lockManager.setLock mailTaskName, lockDurationMs, (err, lockResult) -> +### +### End New Recruit Leaderboard Email ### + +### Employer Matching Candidate Notification Email ### +### +employerMatchingCandidateNotificationTask = -> + # tem_mYsepTfWQ265noKfZJcbBH + #save email filters in their own collection + mailTaskName = "employerMatchingCandidateNotificationTask" + lockDurationMs = 6000 + lockManager.setLock mailTaskName, lockDurationMs, (err, lockResult) -> +### +### End Employer Matching Candidate Notification Email ### +### Ladder Update Email ### DEBUGGING = false LADDER_PREGAME_INTERVAL = 2 * 3600 * 1000 # Send emails two hours before players last submitted. @@ -28,6 +341,8 @@ isRequestFromDesignatedCronHandler = (req, res) -> return false return true + + handleLadderUpdate = (req, res) -> log.info('Going to see about sending ladder update emails.') requestIsFromDesignatedCronHandler = isRequestFromDesignatedCronHandler req, res @@ -151,6 +466,7 @@ getScoreHistoryGraphURL = (session, daysAgo) -> chartData = times.join(',') + '|' + scores.join(',') "https://chart.googleapis.com/chart?chs=600x75&cht=lxy&chtt=Score%3A+#{currentScore}&chts=222222,12,r&chf=a,s,000000FF&chls=2&chd=t:#{chartData}&chxt=y&chxr=0,#{minScore},#{maxScore}" +### End Ladder Update Email ### handleMailchimpWebHook = (req, res) -> post = req.body diff --git a/server_config.coffee b/server_config.coffee index 6018bf84f..13fc4e7d1 100644 --- a/server_config.coffee +++ b/server_config.coffee @@ -12,6 +12,10 @@ config.mongo = host: process.env.COCO_MONGO_HOST or 'localhost' db: process.env.COCO_MONGO_DATABASE_NAME or 'coco' mongoose_replica_string: process.env.COCO_MONGO_MONGOOSE_REPLICA_STRING or '' + +config.redis = + port: process.env.COCO_REDIS_PORT or 6379 + host: process.env.COCO_REDIS_HOST or 'localhost' if config.unittest config.port += 1 diff --git a/test/app/lib/world/ellipse.spec.coffee b/test/app/lib/world/ellipse.spec.coffee index bb71fdad5..8066898be 100644 --- a/test/app/lib/world/ellipse.spec.coffee +++ b/test/app/lib/world/ellipse.spec.coffee @@ -3,97 +3,98 @@ describe 'Ellipse', -> Rectangle = require 'lib/world/rectangle' Vector = require 'lib/world/vector' - #it 'contains its own center', -> - # ellipse = new Ellipse 0, 0, 10, 10 - # expect(ellipse.containsPoint(new Vector 0, 0)).toBe true - # - #it 'contains a point when rotated', -> - # ellipse = new Ellipse 0, -20, 40, 40, 3 * Math.PI / 4 - # p = new Vector 0, 2 - # expect(ellipse.containsPoint(p, true)).toBe true - # - #it 'contains more points properly', -> - # # ellipse with y major axis, off-origin center, and 45 degree rotation - # ellipse = new Ellipse 1, 2, 4, 6, Math.PI / 4 - # expect(ellipse.contains new Vector(1, 2)).toBe true - # expect(ellipse.contains new Vector(-1, 3)).toBe true - # expect(ellipse.contains new Vector(0, 4)).toBe true - # expect(ellipse.contains new Vector(1, 4)).toBe true - # expect(ellipse.contains new Vector(3, 0)).toBe true - # expect(ellipse.contains new Vector(1, 0)).toBe true - # expect(ellipse.contains new Vector(0, 1)).toBe true - # expect(ellipse.contains new Vector(-1, 2)).toBe true - # expect(ellipse.contains new Vector(2, 2)).toBe true - # expect(ellipse.contains new Vector(0, 0)).toBe false - # expect(ellipse.contains new Vector(0, 5)).toBe false - # expect(ellipse.contains new Vector(3, 4)).toBe false - # expect(ellipse.contains new Vector(4, 0)).toBe false - # expect(ellipse.contains new Vector(2, -1)).toBe false - # expect(ellipse.contains new Vector(0, -3)).toBe false - # expect(ellipse.contains new Vector(-2, -2)).toBe false - # expect(ellipse.contains new Vector(-2, 0)).toBe false - # expect(ellipse.contains new Vector(-2, 4)).toBe false - # - #it 'correctly calculates distance to a faraway point', -> - # ellipse = new Ellipse 100, 50, 20, 40 - # p = new Vector 200, 300 - # d = 10 * Math.sqrt(610) - # expect(ellipse.distanceToPoint(p)).toBeCloseTo d - # ellipse.rotation = Math.PI / 2 - # d = 80 * Math.sqrt(10) - # expect(ellipse.distanceToPoint(p)).toBeCloseTo d - # - #it 'does not modify itself or target Vector when calculating distance', -> - # ellipse = new Ellipse -100, -200, 1, 100 - # ellipse2 = ellipse.copy() - # p = new Vector -100.25, -101 - # p2 = p.copy() - # ellipse.distanceToPoint(p) - # expect(p.x).toEqual p2.x - # expect(p.y).toEqual p2.y - # expect(ellipse.x).toEqual ellipse2.x - # expect(ellipse.y).toEqual ellipse2.y - # expect(ellipse.width).toEqual ellipse2.width - # expect(ellipse.height).toEqual ellipse2.height - # expect(ellipse.rotation).toEqual ellipse2.rotation - # - #it 'correctly calculates distance to contained point', -> - # ellipse = new Ellipse -100, -200, 1, 100 - # ellipse2 = ellipse.copy() - # p = new Vector -100.25, -160 - # p2 = p.copy() - # expect(ellipse.distanceToPoint(p)).toBe 0 - # ellipse.rotation = 0.00000001 * Math.PI - # expect(ellipse.distanceToPoint(p)).toBe 0 - # - #it 'AABB works when not rotated', -> - # ellipse = new Ellipse 10, 20, 30, 40 - # rect = new Rectangle 10, 20, 30, 40 - # aabb1 = ellipse.axisAlignedBoundingBox() - # aabb2 = ellipse.axisAlignedBoundingBox() - # for prop in ['x', 'y', 'width', 'height'] - # expect(aabb1[prop]).toBe aabb2[prop] - # - #it 'AABB works when rotated', -> - # ellipse = new Ellipse 10, 20, 30, 40, Math.PI / 3 - # rect = new Rectangle 10, 20, 30, 40, Math.PI / 3 - # aabb1 = ellipse.axisAlignedBoundingBox() - # aabb2 = ellipse.axisAlignedBoundingBox() - # for prop in ['x', 'y', 'width', 'height'] - # expect(aabb1[prop]).toBe aabb2[prop] - # - #it 'calculates ellipse intersections properly', -> - # # ellipse with y major axis, off-origin center, and 45 degree rotation - # ellipse = new Ellipse 1, 2, 4, 6, Math.PI / 4 - # expect(ellipse.intersectsShape new Rectangle(0, 0, 2, 2, 0)).toBe true - # expect(ellipse.intersectsShape new Rectangle(0, -1, 2, 3, 0)).toBe true - # expect(ellipse.intersectsShape new Rectangle(-1, -0.5, 2 * Math.SQRT2, 2 * Math.SQRT2, Math.PI / 4)).toBe true - # expect(ellipse.intersectsShape new Rectangle(-1, -0.5, 2 * Math.SQRT2, 2 * Math.SQRT2, 0)).toBe true - # expect(ellipse.intersectsShape new Rectangle(-1, -1, 2 * Math.SQRT2, 2 * Math.SQRT2, 0)).toBe true - # expect(ellipse.intersectsShape new Rectangle(-1, -1, 2 * Math.SQRT2, 2 * Math.SQRT2, Math.PI / 4)).toBe false - # expect(ellipse.intersectsShape new Rectangle(-2, -2, 2, 2, 0)).toBe false - # expect(ellipse.intersectsShape new Rectangle(-Math.SQRT2 / 2, -Math.SQRT2 / 2, Math.SQRT2, Math.SQRT2, 0)).toBe false - # expect(ellipse.intersectsShape new Rectangle(-Math.SQRT2 / 2, -Math.SQRT2 / 2, Math.SQRT2, Math.SQRT2, Math.PI / 4)).toBe false - # expect(ellipse.intersectsShape new Rectangle(-2, 0, 2, 2, 0)).toBe false - # expect(ellipse.intersectsShape new Rectangle(0, -2, 2, 2, 0)).toBe false - # expect(ellipse.intersectsShape new Rectangle(1, 2, 1, 1, 0)).toBe true + it 'contains its own center', -> + ellipse = new Ellipse 0, 0, 10, 10 + expect(ellipse.containsPoint(new Vector 0, 0)).toBe true + + it 'contains a point when rotated', -> + ellipse = new Ellipse 0, -20, 40, 40, 3 * Math.PI / 4 + expect(ellipse.containsPoint new Vector(0, 0)).toBe true + expect(ellipse.containsPoint new Vector(0, 2)).toBe false + + it 'contains more points properly', -> + # ellipse with y major axis, off-origin center, and 45 degree rotation + ellipse = new Ellipse 1, 2, 4, 6, Math.PI / 4 + expect(ellipse.containsPoint new Vector(1, 2)).toBe true + expect(ellipse.containsPoint new Vector(-1, 3)).toBe true + expect(ellipse.containsPoint new Vector(0, 4)).toBe true + expect(ellipse.containsPoint new Vector(1, 4)).toBe true + expect(ellipse.containsPoint new Vector(3, 0)).toBe true + expect(ellipse.containsPoint new Vector(1, 0)).toBe true + expect(ellipse.containsPoint new Vector(0, 1)).toBe true + expect(ellipse.containsPoint new Vector(-1, 2)).toBe true + expect(ellipse.containsPoint new Vector(2, 2)).toBe true + expect(ellipse.containsPoint new Vector(0, 0)).toBe false + expect(ellipse.containsPoint new Vector(0, 5)).toBe false + expect(ellipse.containsPoint new Vector(3, 4)).toBe false + expect(ellipse.containsPoint new Vector(4, 0)).toBe false + expect(ellipse.containsPoint new Vector(2, -1)).toBe false + expect(ellipse.containsPoint new Vector(0, -3)).toBe false + expect(ellipse.containsPoint new Vector(-2, -2)).toBe false + expect(ellipse.containsPoint new Vector(-2, 0)).toBe false + expect(ellipse.containsPoint new Vector(-2, 4)).toBe false + + xit 'correctly calculates distance to a faraway point', -> + # TODO: this is the correct distance if the ellipse were a rectangle, but need to update for actual ellipse expected distances. + ellipse = new Ellipse 100, 50, 20, 40 + p = new Vector 200, 300 + d = 10 * Math.sqrt(610) + expect(ellipse.distanceToPoint(p)).toBeCloseTo d + ellipse.rotation = Math.PI / 2 + d = 80 * Math.sqrt(10) + expect(ellipse.distanceToPoint(p)).toBeCloseTo d + + it 'does not modify itself or target Vector when calculating distance', -> + ellipse = new Ellipse -100, -200, 1, 100 + ellipse2 = ellipse.copy() + p = new Vector -100.25, -101 + p2 = p.copy() + ellipse.distanceToPoint(p) + expect(p.x).toEqual p2.x + expect(p.y).toEqual p2.y + expect(ellipse.x).toEqual ellipse2.x + expect(ellipse.y).toEqual ellipse2.y + expect(ellipse.width).toEqual ellipse2.width + expect(ellipse.height).toEqual ellipse2.height + expect(ellipse.rotation).toEqual ellipse2.rotation + + it 'correctly calculates distance to contained point', -> + ellipse = new Ellipse -100, -200, 1, 100 + ellipse2 = ellipse.copy() + p = new Vector -100.25, -160 + p2 = p.copy() + expect(ellipse.distanceToPoint(p)).toBe 0 + ellipse.rotation = 0.00000001 * Math.PI + expect(ellipse.distanceToPoint(p)).toBe 0 + + it 'AABB works when not rotated', -> + ellipse = new Ellipse 10, 20, 30, 40 + rect = new Rectangle 10, 20, 30, 40 + aabb1 = ellipse.axisAlignedBoundingBox() + aabb2 = ellipse.axisAlignedBoundingBox() + for prop in ['x', 'y', 'width', 'height'] + expect(aabb1[prop]).toBe aabb2[prop] + + it 'AABB works when rotated', -> + ellipse = new Ellipse 10, 20, 30, 40, Math.PI / 3 + rect = new Rectangle 10, 20, 30, 40, Math.PI / 3 + aabb1 = ellipse.axisAlignedBoundingBox() + aabb2 = ellipse.axisAlignedBoundingBox() + for prop in ['x', 'y', 'width', 'height'] + expect(aabb1[prop]).toBe aabb2[prop] + + it 'calculates ellipse intersections properly', -> + # ellipse with y major axis, off-origin center, and 45 degree rotation + ellipse = new Ellipse 1, 2, 4, 6, Math.PI / 4 + expect(ellipse.intersectsShape new Rectangle(0, 0, 2, 2, 0)).toBe true + expect(ellipse.intersectsShape new Rectangle(0, -1, 2, 3, 0)).toBe true + expect(ellipse.intersectsShape new Rectangle(-1, -0.5, 2 * Math.SQRT2, 2 * Math.SQRT2, Math.PI / 4)).toBe true + expect(ellipse.intersectsShape new Rectangle(-1, -0.5, 2 * Math.SQRT2, 2 * Math.SQRT2, 0)).toBe true + expect(ellipse.intersectsShape new Rectangle(-1, -1, 2 * Math.SQRT2, 2 * Math.SQRT2, 0)).toBe true + expect(ellipse.intersectsShape new Rectangle(-1, -1, 2 * Math.SQRT2, 2 * Math.SQRT2, Math.PI / 4)).toBe false + expect(ellipse.intersectsShape new Rectangle(-2, -2, 2, 2, 0)).toBe false + expect(ellipse.intersectsShape new Rectangle(-Math.SQRT2 / 2, -Math.SQRT2 / 2, Math.SQRT2, Math.SQRT2, 0)).toBe false + expect(ellipse.intersectsShape new Rectangle(-Math.SQRT2 / 2, -Math.SQRT2 / 2, Math.SQRT2, Math.SQRT2, Math.PI / 4)).toBe false + expect(ellipse.intersectsShape new Rectangle(-2, 0, 2, 2, 0)).toBe false + expect(ellipse.intersectsShape new Rectangle(0, -2, 2, 2, 0)).toBe false + expect(ellipse.intersectsShape new Rectangle(1, 2, 1, 1, 0)).toBe true diff --git a/test/app/lib/world/line_segment.spec.coffee b/test/app/lib/world/line_segment.spec.coffee index d3b6ca939..4fef852c8 100644 --- a/test/app/lib/world/line_segment.spec.coffee +++ b/test/app/lib/world/line_segment.spec.coffee @@ -28,13 +28,13 @@ describe 'LineSegment', -> it 'can tell when a point is on a line or segment', -> lineSegment = new LineSegment v00, v11 expect(lineSegment.pointOnLine v22, false).toBe true - #expect(lineSegment.pointOnLine v22, true).toBe false - #expect(lineSegment.pointOnLine v00, false).toBe true - #expect(lineSegment.pointOnLine v00, true).toBe true - #expect(lineSegment.pointOnLine v11, true).toBe true - #expect(lineSegment.pointOnLine v11, false).toBe true - #expect(lineSegment.pointOnLine v34, false).toBe false - #expect(lineSegment.pointOnLine v34, true).toBe false + expect(lineSegment.pointOnLine v22, true).toBe false + expect(lineSegment.pointOnLine v00, false).toBe true + expect(lineSegment.pointOnLine v00, true).toBe true + expect(lineSegment.pointOnLine v11, true).toBe true + expect(lineSegment.pointOnLine v11, false).toBe true + expect(lineSegment.pointOnLine v34, false).toBe false + expect(lineSegment.pointOnLine v34, true).toBe false it 'correctly calculates distance to points', -> lineSegment = new LineSegment v00, v11