commit 421647ff6347f99c32642c1ee25a78f93f76fb5a Author: James Allen Date: Fri Aug 15 12:13:35 2014 +0100 Initial open source commit diff --git a/services/spelling/.gitignore b/services/spelling/.gitignore new file mode 100644 index 0000000000..1d056a1921 --- /dev/null +++ b/services/spelling/.gitignore @@ -0,0 +1,6 @@ +**.swp +**.swo +app/js/* +app.js +test/UnitTests/js/* +node_modules/* diff --git a/services/spelling/app.coffee b/services/spelling/app.coffee new file mode 100644 index 0000000000..1884a845e2 --- /dev/null +++ b/services/spelling/app.coffee @@ -0,0 +1,26 @@ +Settings = require 'settings-sharelatex' +logger = require 'logger-sharelatex' +logger.initialize("spelling-sharelatex") +SpellingAPIController = require './app/js/SpellingAPIController' +restify = require 'restify' +Path = require("path") +metrics = require("metrics-sharelatex") +metrics.initialize("tpds") +metrics.mongodb.monitor(Path.resolve(__dirname + "/node_modules/mongojs/node_modules/mongodb"), logger) + +server = restify.createServer + name: "spelling-sharelatex", + version: "0.0.1" + +server.use restify.bodyParser(mapParams: false) +server.use metrics.http.monitor(logger) + +server.post "/user/:user_id/check", SpellingAPIController.check +server.post "/user/:user_id/learn", SpellingAPIController.learn +server.get "/status", (req, res)-> + res.send(status:'spelling api is up') + +host = Settings.host || "localhost" +port = Settings.port || 3005 +server.listen port, host, () -> + console.log "#{server.name} listening at #{host}:#{port}" diff --git a/services/spelling/app/coffee/ASpell.coffee b/services/spelling/app/coffee/ASpell.coffee new file mode 100644 index 0000000000..4958351458 --- /dev/null +++ b/services/spelling/app/coffee/ASpell.coffee @@ -0,0 +1,100 @@ +child_process = require("child_process") +async = require "async" +_ = require "underscore" + + +class ASpellRunner + checkWords: (language, words, callback = (error, result) ->) -> + @runAspellOnWords language, words, (error, output) => + return callback(error) if error? + output = @removeAspellHeader(output) + suggestions = @getSuggestions(output) + results = [] + for word, i in words + if suggestions[word]? + results.push index: i, suggestions: suggestions[word] + callback null, results + + getSuggestions: (output) -> + lines = output.split("\n") + suggestions = {} + for line in lines + if line[0] == "&" # Suggestions found + parts = line.split(" ") + if parts.length > 1 + word = parts[1] + suggestionsString = line.slice(line.indexOf(":") + 2) + suggestions[word] = suggestionsString.split(", ") + else if line[0] == "#" # No suggestions + parts = line.split(" ") + if parts.length > 1 + word = parts[1] + suggestions[word] = [] + return suggestions + + removeAspellHeader: (output) -> output.slice(1) + + runAspellOnWords: (language, words, callback = (error, output) ->) -> + @open(language) + @captureOutput(callback) + @setTerseMode() + start = new Date() + i = 0 + do tick = () => + if new Date() - start > ASpell.ASPELL_TIMEOUT + @close(true) + else if i < words.length + word = words[i] + @sendWord(word) + i++ + process.nextTick tick + else + @close() + + captureOutput: (callback = (error, output) ->) -> + output = "" + error = "" + @aspell.stdout.on "data", (chunk) -> + output = output + chunk + @aspell.stderr.on "data", (chunk) => + error = error + chunk + @aspell.stdout.on "end", () -> + if error == "" + callback null, output + else + callback new Error(error), output + + open: (language) -> + @finished = false + @aspell = child_process.spawn("aspell", ["pipe", "-t", "--encoding=utf-8", "-d", language]) + + close: (force) -> + @finished = true + @aspell.stdin.end() + if force && !@aspell.exitCode? + @aspell.kill("SIGKILL") + + setTerseMode: () -> + @sendCommand("!") + + sendWord: (word) -> + @sendCommand("^" + word) + + sendCommand: (command) -> + @aspell.stdin.write(command + "\n") + +module.exports = ASpell = + # The description of how to call aspell from another program can be found here: + # http://aspell.net/man-html/Through-A-Pipe.html + checkWords: (language, words, callback = (error, result) ->) -> + runner = new ASpellRunner() + callback = _.once callback + runner.checkWords language, words, callback + + forceClose = -> + runner.close(true) + callback("process killed") + setTimeout forceClose, @ASPELL_TIMEOUT + ASPELL_TIMEOUT : 4000 + + diff --git a/services/spelling/app/coffee/Cache.coffee b/services/spelling/app/coffee/Cache.coffee new file mode 100644 index 0000000000..132254f0d3 --- /dev/null +++ b/services/spelling/app/coffee/Cache.coffee @@ -0,0 +1,35 @@ +redis = require('redis') +settings = require('settings-sharelatex') +rclient = redis.createClient(settings.redis.port, settings.redis.host) +rclient.auth(settings.redis.password) +logger = require('logger-sharelatex') + +thirtyMinutes = (60 * 60 * 30) + +module.exports = + + break: (key, callback)-> + rclient.del buildKey(key), callback + + set :(key, value, callback)-> + value = JSON.stringify value + builtKey = buildKey(key) + multi = rclient.multi() + multi.set builtKey, value + multi.expire builtKey, thirtyMinutes + multi.exec callback + + get :(key, callback)-> + builtKey = buildKey(key) + rclient.get builtKey, (err, result)-> + return callback(err) if err? + if !result? + logger.log key:key, "cache miss" + callback() + else + result = JSON.parse result + logger.log key:key, foundId:result._id, "cache hit" + callback null, result + +buildKey = (key)-> + return "user-learned-words:#{key}" diff --git a/services/spelling/app/coffee/DB.coffee b/services/spelling/app/coffee/DB.coffee new file mode 100644 index 0000000000..be661fd2ce --- /dev/null +++ b/services/spelling/app/coffee/DB.coffee @@ -0,0 +1,4 @@ +MongoJS = require "mongojs" +Settings = require "settings-sharelatex" +module.exports = MongoJS(Settings.mongo.url, ["spellingPreferences"]) + diff --git a/services/spelling/app/coffee/LearnedWordsManager.coffee b/services/spelling/app/coffee/LearnedWordsManager.coffee new file mode 100644 index 0000000000..04febf6b8f --- /dev/null +++ b/services/spelling/app/coffee/LearnedWordsManager.coffee @@ -0,0 +1,18 @@ +db = require("./DB") + +module.exports = LearnedWordsManager = + learnWord: (user_token, word, callback = (error)->) -> + db.spellingPreferences.update { + token: user_token + }, { + $push: learnedWords: word + }, { + upsert: true + }, callback + + getLearnedWords: (user_token, callback = (error, words)->) -> + db.spellingPreferences.findOne token: user_token, (error, preferences) -> + return callback error if error? + callback null, (preferences?.learnedWords || []) + + diff --git a/services/spelling/app/coffee/Metrics.coffee b/services/spelling/app/coffee/Metrics.coffee new file mode 100644 index 0000000000..10d00aa11d --- /dev/null +++ b/services/spelling/app/coffee/Metrics.coffee @@ -0,0 +1,23 @@ +StatsD = require('node-statsd').StatsD +statsd = new StatsD('localhost',8125) + +buildKey = (key)-> "spelling.#{process.env.NODE_ENV}.#{key}" + +module.exports = + inc : (key, sampleRate)-> + statsd.increment buildKey(key, sampleRate) + + Timer : class + constructor :(key)-> + this.start = new Date() + this.key = buildKey(key) + done:-> + timeSpan = new Date - this.start + statsd.timing("#{this.key}-time", timeSpan) + statsd.increment "#{this.key}-count" + + gauge : (key, value, sampleRate)-> + stats = {}; + stat = buildKey(key) + stats[stat] = value+"|g"; + statsd.send(stats, sampleRate); diff --git a/services/spelling/app/coffee/SpellingAPIController.coffee b/services/spelling/app/coffee/SpellingAPIController.coffee new file mode 100644 index 0000000000..e98d9736d8 --- /dev/null +++ b/services/spelling/app/coffee/SpellingAPIController.coffee @@ -0,0 +1,31 @@ +SpellingAPIManager = require './SpellingAPIManager' +restify = require 'restify' +logger = require 'logger-sharelatex' +metrics = require('./Metrics') + +module.exports = SpellingAPIController = + check: (req, res, next) -> + metrics.inc "spelling-check", 0.1 + if req.is("json") + logger.log token: req?.params?.user_id, word_count: req?.body?.words?.length, "running check" + SpellingAPIManager.runRequest req.params.user_id, req.body, (error, result) -> + if err? + logger.err err:err, user_id:req?.params?.user_id, word_count: req?.body?.words?.length, "error processing spelling request" + return res.send(500) + res.send(result) + else + next(new restify.NotAcceptableError("Please provide a JSON request")) + + learn: (req, res, next) -> + metrics.inc "spelling-learn", 0.1 + if req.is("json") + logger.log token: req?.params?.user_id, word: req?.body?.word, "learning word" + SpellingAPIManager.learnWord req.params.user_id, req.body, (error, result) -> + return next(error) if error? + res.send(200) + next() + else + next(new restify.NotAcceptableError("Please provide a JSON request")) + + + diff --git a/services/spelling/app/coffee/SpellingAPIManager.coffee b/services/spelling/app/coffee/SpellingAPIManager.coffee new file mode 100644 index 0000000000..cb6b4fa326 --- /dev/null +++ b/services/spelling/app/coffee/SpellingAPIManager.coffee @@ -0,0 +1,37 @@ +ASpell = require './ASpell' +LearnedWordsManager = require './LearnedWordsManager' +async = require 'async' + +module.exports = SpellingAPIManager = + runRequest: (token, request, callback = (error, result) ->) -> + if !request.words? + return callback(new Error("malformed JSON")) + + lang = request.language || "en" + + check = (words, callback) -> + ASpell.checkWords lang, words, (error, misspellings) -> + callback error, misspellings: misspellings + + if token? + LearnedWordsManager.getLearnedWords token, (error, learnedWords) -> + return callback(error) if error? + words = (request.words || []).slice(0,10000) + check words, (error, result) -> + return callback error if error? + result.misspellings = result.misspellings.filter (m) -> + word = words[m.index] + learnedWords.indexOf(word) == -1 + callback error, result + else + check(request.words, callback) + + learnWord: (token, request, callback = (error) ->) -> + if !request.word? + return callback(new Error("malformed JSON")) + if !token? + return callback(new Error("no token provided")) + + LearnedWordsManager.learnWord token, request.word, callback + + diff --git a/services/spelling/config/settings.development.coffee b/services/spelling/config/settings.development.coffee new file mode 100644 index 0000000000..a3183ca0fe --- /dev/null +++ b/services/spelling/config/settings.development.coffee @@ -0,0 +1,10 @@ +module.exports = Settings = + port: 3005 + host: "localhost" + redis: + port:6379 + host:"127.0.0.1" + password:"" + mongo: + url : 'mongodb://127.0.0.1/sharelatexTesting' + diff --git a/services/spelling/package.json b/services/spelling/package.json new file mode 100644 index 0000000000..a060972d5a --- /dev/null +++ b/services/spelling/package.json @@ -0,0 +1,23 @@ +{ + "name": "spelling-sharelatex", + "author": "ShareLaTeX ", + "description": "A JSON API wrapper around aspell", + "version": "0.0.1", + "dependencies": { + "express": "3.1.0", + "async": "0.1.22", + "restify": "2.5.1", + "settings": "git+ssh://git@bitbucket.org:sharelatex/settings-sharelatex.git#master", + "logger": "git+ssh://git@bitbucket.org:sharelatex/logger-sharelatex.git#bunyan", + "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#master", + "node-statsd": "0.0.3", + "underscore": "1.4.4", + "mongojs": "0.9.11", + "redis": "~0.8.4" + }, + "devDependencies": { + "sinon": "", + "chai": "", + "sandboxed-module": "" + } +} diff --git a/services/spelling/rakefile.rb b/services/spelling/rakefile.rb new file mode 100644 index 0000000000..f7ceff9db7 --- /dev/null +++ b/services/spelling/rakefile.rb @@ -0,0 +1,49 @@ +namespace 'run' do + desc "compiles and runs the spelling-sharelatex server" + task :app => ["compile:app"] do + sh %{node app.js | bunyan} + end +end + +namespace 'compile' do + desc "compiles application files" + task :app do + FileUtils.rm_rf "app/js" + sh %{coffee -c -o app/js/ app/coffee/} do |ok, res| + if ! ok + raise "error compiling app folder tests : #{res}" + end + puts 'finished app/coffee compile' + end + sh %{coffee -c app.coffee} do |ok, res| + if ! ok + raise "error compiling root app file: #{res}" + end + puts 'finished app.coffee compile' + end + end + + desc "compiles unit tests" + task :unit_tests => ["compile:app"] do + FileUtils.rm_rf "test/UnitTests/js" + puts "Compiling Unit Tests to JS" + sh %{coffee -c -o test/UnitTests/js/ test/UnitTests/coffee/} do |ok, res| + if ! ok + raise "error compiling tests : #{res}" + end + puts 'finished unit tests compile' + end + end +end + +namespace 'test' do + desc "Run Unit Tests" + task :unit => ["compile:unit_tests"]do + puts "Running Unit Tests" + sh %{mocha -R spec test/UnitTests/js/*} do |ok, res| + if ! ok + raise "error running unit tests : #{res}" + end + end + end +end diff --git a/services/spelling/test/UnitTests/coffee/ASpellTests.coffee b/services/spelling/test/UnitTests/coffee/ASpellTests.coffee new file mode 100644 index 0000000000..99c0d8a7b9 --- /dev/null +++ b/services/spelling/test/UnitTests/coffee/ASpellTests.coffee @@ -0,0 +1,58 @@ +sinon = require 'sinon' +chai = require 'chai' +should = chai.should() + +describe "ASpell", -> + beforeEach -> + @ASpell = require("../../../app/js/ASpell") + + describe "a correctly spelled word", -> + beforeEach (done) -> + @ASpell.checkWords "en", ["word"], (error, @result) => done() + + it "should not correct the word", -> + @result.length.should.equal 0 + + describe "a misspelled word", -> + beforeEach (done) -> + @ASpell.checkWords "en", ["bussines"], (error, @result) => done() + + it "should correct the word", -> + @result.length.should.equal 1 + @result[0].suggestions.indexOf("business").should.not.equal -1 + + describe "multiple words", -> + beforeEach (done) -> + @ASpell.checkWords "en", ["bussines", "word", "neccesary"], (error, @result) => done() + + it "should correct the incorrect words", -> + @result[0].index.should.equal 0 + @result[0].suggestions.indexOf("business").should.not.equal -1 + @result[1].index.should.equal 2 + @result[1].suggestions.indexOf("necessary").should.not.equal -1 + + describe "without a valid language", -> + beforeEach (done) -> + @ASpell.checkWords "notALang", ["banana"], (@error, @result) => done() + + it "should return an error", -> + should.exist @error + + describe "when there are no suggestions", -> + beforeEach (done) -> + @ASpell.checkWords "en", ["asdkfjalkdjfadhfkajsdhfashdfjhadflkjadhflajsd"], (@error, @result) => done() + + it "should return a blank array", -> + @result.length.should.equal 1 + @result[0].suggestions.should.deep.equal [] + + describe "when the request times out", -> + beforeEach (done) -> + words = ("abcdefg" for i in [0..1000000]) + @ASpell.ASPELL_TIMEOUT = 100 + @start = new Date() + @ASpell.checkWords "en", words, (error, @result) => done() + + it "should return in reasonable time", (done) -> + done() + diff --git a/services/spelling/test/UnitTests/coffee/CacheTests.coffee b/services/spelling/test/UnitTests/coffee/CacheTests.coffee new file mode 100644 index 0000000000..e4b8e71b7e --- /dev/null +++ b/services/spelling/test/UnitTests/coffee/CacheTests.coffee @@ -0,0 +1,56 @@ +modulePath = "../../../app/js/Cache.js" +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +assert = require('chai').assert +path = require 'path' + +user_token = "23ionisou90iilkn" +spellings = ["bob", "smith", "words"] + +describe 'Cache', -> + + it 'should save the user into redis', (done)-> + @redis = + expire: (key, value)-> + key.should.equal "user-learned-words:#{user_token}" + (value > 200).should.equal true + set: (key, value)-> + key.should.equal "user-learned-words:#{user_token}" + value.should.equal JSON.stringify(spellings) + exec:-> + done() + @cache = SandboxedModule.require modulePath, requires: + 'redis': createClient :=> {multi:=> @redis} + + @cache.set user_token, spellings, -> + + it 'should get the user from redis', (done)-> + @redis = get: (key, cb)-> + key.should.equal "user-learned-words:#{user_token}" + cb(null, JSON.stringify(spellings)) + + @cache = SandboxedModule.require modulePath, requires: + 'redis': createClient :=> return @redis + + @cache.get user_token, (err, returnedSpellings)-> + assert.deepEqual returnedSpellings, spellings + assert.equal err, null + done() + + it 'should return nothing if the key doesnt exist', (done)-> + @redis = get: (key, cb)-> + cb(null, null) + @cache = SandboxedModule.require modulePath, requires: + 'redis': createClient :=> return @redis + + @cache.get user_token, (err, founduser)-> + assert.equal founduser, undefined + done() + + it 'should be able to delete from redis to break cache', (done)-> + @redis = del: (key, cb)-> + key.should.equal "user-learned-words:#{user_token}" + cb(null) + @cache = SandboxedModule.require modulePath, requires: + 'redis': createClient :=> return @redis + @cache.break user_token, done diff --git a/services/spelling/test/UnitTests/coffee/LearnedWordsManagerTests.coffee b/services/spelling/test/UnitTests/coffee/LearnedWordsManagerTests.coffee new file mode 100644 index 0000000000..22b73c32aa --- /dev/null +++ b/services/spelling/test/UnitTests/coffee/LearnedWordsManagerTests.coffee @@ -0,0 +1,83 @@ +sinon = require('sinon') +chai = require 'chai' +expect = chai.expect +SandboxedModule = require('sandboxed-module') +modulePath = require('path').join __dirname, '../../../app/js/LearnedWordsManager' + +describe "LearnedWordsManager", -> + beforeEach -> + @token = "a6b3cd919ge" + @callback = sinon.stub() + @db = + spellingPreferences: + update: sinon.stub().callsArg(3) + @cache = + get: sinon.stub() + set: sinon.stub() + break: sinon.stub() + @LearnedWordsManager = SandboxedModule.require modulePath, requires: + "./DB" : @db + "./Cache":@cache + + + describe "learnWord", -> + beforeEach -> + @word = "instanton" + @LearnedWordsManager.learnWord @token, @word, @callback + + it "should insert the word in the word list in the database", -> + expect( + @db.spellingPreferences.update.calledWith({ + token: @token + }, { + $push : learnedWords: @word + }, { + upsert: true + }) + ).to.equal true + + it "should call the callback", -> + expect(@callback.called).to.equal true + + describe "getLearnedWords", -> + beforeEach -> + @cache.get.callsArgWith(1) + @wordList = ["apples", "bananas", "pears"] + @db.spellingPreferences.findOne = (conditions, callback) => + callback null, learnedWords: @wordList + sinon.spy @db.spellingPreferences, "findOne" + @LearnedWordsManager.getLearnedWords @token, @callback + + it "should get the word list for the given user", -> + expect( + @db.spellingPreferences.findOne.calledWith token: @token + ).to.equal true + + it "should return the word list in the callback", -> + expect(@callback.calledWith null, @wordList).to.equal true + + ### + describe "caching the result", -> + it 'should use the cache first if it is primed', (done)-> + @wordList = ["apples", "bananas", "pears"] + @cache.get.callsArgWith(1, null, learnedWords: @wordList) + @db.spellingPreferences.findOne = sinon.stub() + @LearnedWordsManager.getLearnedWords @token, (err, spellings)=> + @db.spellingPreferences.find.called.should.equal false + @wordList.should.equal spellings + done() + + it 'should set the cache after hitting the db', (done)-> + @wordList = ["apples", "bananas", "pears"] + @cache.get.callsArgWith(1) + @db.spellingPreferences.findOne = sinon.stub().callsArgWith(1, null, learnedWords: @wordList) + @LearnedWordsManager.getLearnedWords @token, (err, spellings)=> + @cache.set.calledWith(@token, learnedWords:@wordList).should.equal true + done() + + it 'should break cache when update is called', (done)-> + @word = "instanton" + @LearnedWordsManager.learnWord @token, @word, => + @cache.break.calledWith(@token).should.equal true + done() + ### diff --git a/services/spelling/test/UnitTests/coffee/SpellingAPIManagerTests.coffee b/services/spelling/test/UnitTests/coffee/SpellingAPIManagerTests.coffee new file mode 100644 index 0000000000..97c43fa07b --- /dev/null +++ b/services/spelling/test/UnitTests/coffee/SpellingAPIManagerTests.coffee @@ -0,0 +1,104 @@ +sinon = require('sinon') +chai = require 'chai' +expect = chai.expect +chai.should() +SandboxedModule = require('sandboxed-module') +modulePath = require('path').join __dirname, '../../../app/js/SpellingAPIManager' + +describe "SpellingAPIManager", -> + beforeEach -> + @token = "user-id-123" + @ASpell = {} + @learnedWords = ["lerned"] + @LearnedWordsManager = + getLearnedWords: sinon.stub().callsArgWith(1, null, @learnedWords) + learnWord: sinon.stub().callsArg(2) + + @SpellingAPIManager = SandboxedModule.require modulePath, requires: + "./ASpell" : @ASpell + "./LearnedWordsManager" : @LearnedWordsManager + + describe "runRequest", -> + beforeEach -> + @nonLearnedWords = ["some", "words", "htat", "are", "speled", "rong", "lerned"] + @allWords = @nonLearnedWords.concat(@learnedWords) + @misspellings = [ + { index: 2, suggestions: ["that"] } + { index: 4, suggestions: ["spelled"] } + { index: 5, suggestions: ["wrong", "ring"] } + { index: 6, suggestions: ["learned"] } + ] + @misspellingsWithoutLearnedWords = @misspellings.slice(0,3) + + @ASpell.checkWords = (lang, word, callback) => + callback null, @misspellings + sinon.spy @ASpell, "checkWords" + + describe "with sensible JSON", -> + beforeEach (done) -> + @SpellingAPIManager.runRequest @token, words: @allWords, (error, @result) => done() + + it "should return the words that are spelled incorrectly and not learned", -> + expect(@result.misspellings).to.deep.equal @misspellingsWithoutLearnedWords + + describe "with a missing words array", -> + beforeEach (done) -> + @SpellingAPIManager.runRequest @token, {}, (@error, @result) => done() + + it "should return an error", -> + expect(@error).to.deep.equal new Error("malformed JSON") + + describe "with a missing token", -> + beforeEach (done) -> + @SpellingAPIManager.runRequest null, words: @allWords, (@error, @result) => done() + + it "should spell check without using any learned words", -> + @LearnedWordsManager.getLearnedWords.called.should.equal false + + describe "without a language", -> + beforeEach (done) -> + @SpellingAPIManager.runRequest @token, words: @allWords, (error, @result) => done() + + it "should use en as the default", -> + @ASpell.checkWords.calledWith("en").should.equal true + + describe "with a language", -> + beforeEach (done) -> + @SpellingAPIManager.runRequest @token, { + words: @allWords + language: @language = "fr" + }, (error, @result) => done() + + it "should use the language", -> + @ASpell.checkWords.calledWith(@language).should.equal true + + describe "with a very large collection of words", -> + beforeEach (done) -> + @manyWords = ("word" for i in [1..100000]) + @SpellingAPIManager.runRequest @token, words: @manyWords, (error, @result) => done() + + it "should truncate to 10,000 words", -> + @ASpell.checkWords.calledWith(sinon.match.any, @manyWords.slice(0, 10000)).should.equal true + + describe "learnWord", -> + describe "without a token", -> + beforeEach (done) -> @SpellingAPIManager.learnWord null, word: "banana", (@error) => done() + + it "should return an error", -> + expect(@error).to.deep.equal new Error("malformed JSON") + + describe "without a word", -> + beforeEach (done) -> @SpellingAPIManager.learnWord @token, {}, (@error) => done() + + it "should return an error", -> + expect(@error).to.deep.equal new Error("no token provided") + + describe "with a word and a token", -> + beforeEach (done) -> + @word = "banana" + @SpellingAPIManager.learnWord @token, word: @word, (@error) => done() + + it "should call LearnedWordsManager.learnWord", -> + @LearnedWordsManager.learnWord.calledWith(@token, @word).should.equal true + +