├── .nvmrc
├── .eslintignore
├── .gitignore
├── icon.ico
├── icon.icns
├── src
├── windows
│ ├── theme.js
│ ├── img
│ │ ├── icon.png
│ │ ├── pause.png
│ │ ├── play.png
│ │ ├── skip.png
│ │ ├── configure.png
│ │ └── sad-cyclops.png
│ ├── timer
│ │ ├── default.mp3
│ │ ├── index.html
│ │ ├── index.css
│ │ └── index.js
│ ├── theme.css
│ ├── fullscreen
│ │ ├── index.html
│ │ ├── index.js
│ │ └── index.css
│ ├── window-snapper.js
│ ├── config
│ │ ├── index.css
│ │ ├── index.html
│ │ └── index.js
│ └── windows.js
├── clipboard.js
├── state
│ ├── state-persister.js
│ ├── timer.js
│ ├── mobbers.js
│ └── timer-state.js
└── main.js
├── timer-example.png
├── .travis.yml
├── test
├── state
│ ├── test-timer.js
│ ├── state-persister.specs.js
│ ├── timer.specs.js
│ ├── mobbers.specs.js
│ └── timer-state.specs.js
├── node-version.specs.js
└── clipboard.specs.js
├── .eslintrc.json
├── package.json
├── README.md
└── LICENSE
/.nvmrc:
--------------------------------------------------------------------------------
1 | 10.14
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/*
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 | *.log
4 | .DS_Store
5 |
--------------------------------------------------------------------------------
/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/icon.ico
--------------------------------------------------------------------------------
/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/icon.icns
--------------------------------------------------------------------------------
/src/windows/theme.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | mobberBorderHighlightColor: '#f15b2a'
3 | }
4 |
--------------------------------------------------------------------------------
/timer-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/timer-example.png
--------------------------------------------------------------------------------
/src/windows/img/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/icon.png
--------------------------------------------------------------------------------
/src/windows/img/pause.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/pause.png
--------------------------------------------------------------------------------
/src/windows/img/play.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/play.png
--------------------------------------------------------------------------------
/src/windows/img/skip.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/skip.png
--------------------------------------------------------------------------------
/src/windows/img/configure.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/configure.png
--------------------------------------------------------------------------------
/src/windows/timer/default.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/timer/default.mp3
--------------------------------------------------------------------------------
/src/windows/img/sad-cyclops.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/sad-cyclops.png
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | cache: npm
3 | node_js:
4 | - "10.14"
5 | os:
6 | - windows
7 | - osx
8 | - linux
9 | script:
10 | - npm run test
11 | - if [[ "$TRAVIS_OS_NAME" == "windows" ]]; then npm run build-win; fi
12 | - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then npm run build-mac; fi
13 | - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then npm run build-linux; fi
14 | after_script:
15 | - ls -R dist
--------------------------------------------------------------------------------
/test/state/test-timer.js:
--------------------------------------------------------------------------------
1 | class TestTimer {
2 | constructor(options, callback) {
3 | this.options = options
4 | this.callback = callback
5 | this.isRunning = false
6 | }
7 |
8 | start() {
9 | this.isRunning = true
10 | }
11 |
12 | pause() {
13 | this.isRunning = false
14 | }
15 |
16 | reset(value) {
17 | this.time = value
18 | }
19 | }
20 |
21 | module.exports = TestTimer
22 |
--------------------------------------------------------------------------------
/src/clipboard.js:
--------------------------------------------------------------------------------
1 | const clipboardy = require('clipboardy')
2 |
3 | module.exports = {
4 | clearClipboardHistory(numberOfItemsHistoryStores) {
5 | const millisecondsNeededBetweenWrites = 180
6 | let i = 1
7 | let id = setInterval(writeToClipboard, millisecondsNeededBetweenWrites)
8 |
9 | function writeToClipboard() {
10 | if (i < numberOfItemsHistoryStores) {
11 | clipboardy.writeSync(i.toString())
12 | i++
13 | } else {
14 | clipboardy.writeSync('')
15 | clearInterval(id)
16 | }
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/test/node-version.specs.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const assert = require('assert')
3 |
4 | describe('Node version', () => {
5 | it('.nvmrc should match .travis.yml', () => {
6 | const nvmrc = fs.readFileSync('./.nvmrc', 'utf-8')
7 | const travisYml = fs.readFileSync('./.travis.yml', 'utf-8')
8 |
9 | const matches = travisYml.indexOf(` - "${nvmrc}"`) !== -1
10 | const message = [
11 | 'Could not find node version from .nvmrc in .travis.yml!\n',
12 | '.nvmrc', nvmrc, '.travis.yml', travisYml]
13 | assert.ok(matches, message.join('\n'))
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/src/windows/theme.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --theme-primary-color: #f15b2a;
3 | --theme-secondary-color: #ec008c;
4 |
5 | --main-text-color: #000;
6 | --main-background-color: #fff;
7 | --disabled-text-color: #999;
8 | --table-stripe-color: #dcdcdc;
9 |
10 | --button-text-color: #fff;
11 | --button-background-color: var(--theme-primary-color);
12 |
13 | --mobber-border-color: #eee;
14 | --mobber-border-highlight-color: var(--theme-primary-color);
15 |
16 | --timer-pulse-text-color: #fff;
17 | --timer-pulse-color-1: var(--theme-primary-color);
18 | --timer-pulse-color-2: var(--theme-secondary-color);
19 |
20 | --timer-paused-text-color: #999;
21 | --timer-paused-background-color: #333;
22 | }
23 |
--------------------------------------------------------------------------------
/src/state/state-persister.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const os = require('os')
3 | const path = require('path')
4 |
5 | const mobTimerDir = path.join(os.homedir(), '.mob-timer')
6 | const stateFile = path.join(mobTimerDir, 'state.json')
7 | const oldStateFile = path.join(os.tmpdir(), 'state.json')
8 |
9 | function read() {
10 | if (fs.existsSync(stateFile)) {
11 | return JSON.parse(fs.readFileSync(stateFile, 'utf-8'))
12 | }
13 | if (fs.existsSync(oldStateFile)) {
14 | return JSON.parse(fs.readFileSync(oldStateFile, 'utf-8'))
15 | }
16 | return {}
17 | }
18 |
19 | function write(state) {
20 | if (!fs.existsSync(mobTimerDir)) {
21 | fs.mkdirSync(mobTimerDir)
22 | }
23 | fs.writeFileSync(stateFile, JSON.stringify(state))
24 | }
25 |
26 | module.exports = {
27 | read,
28 | write,
29 | stateFile,
30 | oldStateFile,
31 | mobTimerDir
32 | }
33 |
--------------------------------------------------------------------------------
/src/windows/fullscreen/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Mob Timer
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
![]()
14 |
15 |
{name}, please sit at the keyboard
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | Up Next:
{name}
24 |
25 |
26 |
27 |
28 |
29 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/test/clipboard.specs.js:
--------------------------------------------------------------------------------
1 | const clipboard = require('../src/clipboard')
2 | const clipboardy = require('clipboardy')
3 | const sinon = require('sinon')
4 | let assert = require('assert')
5 |
6 | describe('clipboard', () => {
7 | describe('clearClipboardHistory', () => {
8 | before(() => {
9 | clipboardy.writeSync('general kenboi')
10 | sinon.spy(clipboardy, 'writeSync')
11 | clipboard.clearClipboardHistory(expectedTimesWriteSyncIsCalled)
12 | })
13 |
14 | after(() => {
15 | clipboardy.writeSync.restore()
16 | })
17 |
18 | it('should have cleared the clip board', function(done) {
19 | setTimeout(function() {
20 | assert.strictEqual(clipboardy.readSync(), '')
21 | done()
22 | }, 700)
23 | })
24 |
25 | it('should call writeSync the correct number of times', () => {
26 | sinon.assert.callCount(clipboardy.writeSync, expectedTimesWriteSyncIsCalled)
27 | })
28 |
29 | let expectedTimesWriteSyncIsCalled = 3
30 | })
31 | })
32 |
--------------------------------------------------------------------------------
/src/windows/fullscreen/index.js:
--------------------------------------------------------------------------------
1 | const ipc = require('electron').ipcRenderer
2 |
3 | const skipBtn = document.getElementById('skip')
4 | const startTurnBtn = document.getElementById('startTurn')
5 | const configureBtn = document.getElementById('configure')
6 | const currentEl = document.getElementById('current')
7 | const currentPicEl = document.getElementById('currentPic')
8 | const nextEl = document.getElementById('next')
9 | const nextPicEl = document.getElementById('nextPic')
10 |
11 | ipc.on('rotated', (event, data) => {
12 | if (!data.current) {
13 | data.current = { name: 'Add a mobber' }
14 | }
15 | currentEl.innerHTML = data.current.name
16 | currentPicEl.src = data.current.image || '../img/sad-cyclops.png'
17 |
18 | if (!data.next) {
19 | data.next = data.current
20 | }
21 | nextEl.innerHTML = data.next.name
22 | nextPicEl.src = data.next.image || '../img/sad-cyclops.png'
23 | })
24 |
25 | skipBtn.addEventListener('click', () => ipc.send('skip'))
26 | startTurnBtn.addEventListener('click', () => ipc.send('startTurn'))
27 | configureBtn.addEventListener('click', () => ipc.send('configure'))
28 |
29 | ipc.send('fullscreenWindowReady')
30 |
--------------------------------------------------------------------------------
/src/windows/timer/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Mob Timer
6 |
7 |
8 |
9 |
10 |
11 |
12 |
![]()
13 |
14 |
15 |
16 |
17 |
{name}
18 |
19 |
20 |
21 |
22 |
![]()
23 |
24 |
{name}
25 |
26 |
27 |
28 |
31 |
32 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/windows/window-snapper.js:
--------------------------------------------------------------------------------
1 | let getEdges = bounds => {
2 | return {
3 | top: bounds.y,
4 | bottom: bounds.y + bounds.height,
5 | left: bounds.x,
6 | right: bounds.x + bounds.width
7 | }
8 | }
9 |
10 | let isCloseTo = (a, b, snapThreshold) => {
11 | return Math.abs(a - b) <= snapThreshold
12 | }
13 |
14 | module.exports = (windowBounds, screenBounds, snapThreshold) => {
15 | if (snapThreshold <= 0) {
16 | return { x: windowBounds.x, y: windowBounds.y }
17 | }
18 |
19 | let windowEdges = getEdges(windowBounds)
20 | let screenEdges = getEdges(screenBounds)
21 | let snapTo = { x: windowBounds.x, y: windowBounds.y }
22 |
23 | if (isCloseTo(windowEdges.left, screenEdges.left, snapThreshold)) {
24 | snapTo.x = screenEdges.left
25 | }
26 |
27 | if (isCloseTo(windowEdges.right, screenEdges.right, snapThreshold)) {
28 | snapTo.x = screenEdges.right - windowBounds.width
29 | }
30 |
31 | if (isCloseTo(windowEdges.top, screenEdges.top, snapThreshold)) {
32 | snapTo.y = screenEdges.top
33 | }
34 |
35 | if (isCloseTo(windowEdges.bottom, screenEdges.bottom, snapThreshold)) {
36 | snapTo.y = screenEdges.bottom - windowBounds.height
37 | }
38 |
39 | return snapTo
40 | }
41 |
--------------------------------------------------------------------------------
/src/state/timer.js:
--------------------------------------------------------------------------------
1 | class Timer {
2 | constructor(options, callback) {
3 | this.rateMilliseconds = options.rateMilliseconds || 1000
4 | this.time = options.time || 0
5 | this.timeDelta = 0
6 | this.countDown = options.countDown === true
7 | this.callback = callback
8 | this.startingTime = null
9 | }
10 |
11 | start(now = Date.now) {
12 | this.startingTime = now()
13 |
14 | if (!this.interval) {
15 | this.interval = setInterval(() => {
16 | const secondsPassed = Math.floor((now() - this.startingTime) / 1000)
17 | this.timeDelta = secondsPassed
18 | const secondsRemaining = this.time - secondsPassed
19 |
20 | this.callback(this.countDown ? secondsRemaining : secondsPassed + this.time)
21 | }, this.rateMilliseconds)
22 | }
23 | }
24 |
25 | pause() {
26 | if (this.interval) {
27 | clearInterval(this.interval)
28 | this.interval = null
29 | }
30 | this.countDown
31 | ? (this.time = this.time - this.timeDelta)
32 | : (this.time += this.timeDelta)
33 | this.timeDelta = 0
34 | }
35 |
36 | reset(value, now = Date.now) {
37 | this.time = value
38 | this.timeDelta = 0
39 | this.startingTime = now()
40 | }
41 | }
42 |
43 | module.exports = Timer
44 |
--------------------------------------------------------------------------------
/src/windows/fullscreen/index.css:
--------------------------------------------------------------------------------
1 | @import url('../theme.css');
2 |
3 | body {
4 | margin: 0;
5 | padding: 0;
6 | font-family: sans-serif;
7 | user-select: none;
8 | color: var(--main-text-color);
9 | background-color: var(--main-background-color);
10 | }
11 | .container {
12 | display: flex;
13 | flex-direction: column;
14 | align-items: center;
15 | justify-content: center;
16 | height: 100vh;
17 | }
18 | .current {
19 | font-size: 38px;
20 | white-space: nowrap;
21 | overflow: hidden;
22 | text-overflow: ellipsis;
23 | width: 90%;
24 | text-align: center;
25 | margin-bottom: 15px;
26 | }
27 | .controls {
28 | display: flex;
29 | margin-bottom: 15px;
30 | }
31 | .btn {
32 | background: var(--button-background-color);
33 | border-radius: 3px;
34 | border: 0;
35 | color: var(--button-text-color);
36 | padding: 10px 20px;
37 | margin: 0 5px;
38 | cursor: pointer;
39 | font-size: 20px;
40 | }
41 | .next {
42 | font-size: 20px;
43 | white-space: nowrap;
44 | overflow: hidden;
45 | text-overflow: ellipsis;
46 | width: 90%;
47 | text-align: center;
48 | }
49 | .pic {
50 | border-radius: 50%;
51 | border: 2px solid var(--mobber-border-color);
52 | }
53 | .current .pic {
54 | width: 150px;
55 | height: 150px;
56 | border-width: 5px;
57 | }
58 | .next .pic {
59 | width: 30px;
60 | height: 30px;
61 | vertical-align: bottom;
62 | }
63 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "eslint:recommended",
4 | "plugin:node/recommended",
5 | "plugin:promise/recommended",
6 | "standard"
7 | ],
8 | "env": {
9 | "mocha": true
10 | },
11 | "plugins": [
12 | "mocha",
13 | "node"
14 | ],
15 | "globals": {
16 | "expect": true
17 | },
18 | "overrides": [
19 | {
20 | "files": "*.spec.js",
21 | "rules": {
22 | "no-unused-expressions": "off"
23 | }
24 | }
25 | ],
26 | "rules": {
27 | "array-bracket-spacing": "error",
28 | "arrow-parens": [
29 | 2,
30 | "as-needed"
31 | ],
32 | "eol-last": "error",
33 | "generator-star-spacing": "off",
34 | "mocha/no-exclusive-tests": "error",
35 | "node/no-unpublished-require": [
36 | "error",
37 | {
38 | "allowModules": [
39 | "clipboardy",
40 | "electron",
41 | "chai",
42 | "sinon"
43 | ]
44 | }
45 | ],
46 | "no-unused-vars": [
47 | 2,
48 | {
49 | "vars": "all",
50 | "args": "after-used",
51 | "ignoreRestSiblings": true
52 | }
53 | ],
54 | "space-before-function-paren": [
55 | "error",
56 | "never"
57 | ],
58 | "standard/object-curly-even-spacing": [
59 | 2,
60 | "always"
61 | ],
62 | "yield-star-spacing": [
63 | "error",
64 | {
65 | "before": false,
66 | "after": true
67 | }
68 | ]
69 | }
70 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mob-timer",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "src/main.js",
6 | "scripts": {
7 | "start": "electron .",
8 | "build-win": "electron-packager . --platform=win32 --arch=x64 --out=dist --icon=icon --overwrite",
9 | "build-mac": "electron-packager . --platform=darwin --arch=x64 --out=dist --icon=icon --overwrite",
10 | "build-linux": "electron-packager . --platform=linux --arch=x64 --out=dist --icon=icon --overwrite",
11 | "lint": "eslint .",
12 | "pretest": "npm run lint",
13 | "test": "mocha --use_strict --recursive",
14 | "tdd": "mocha --use_strict --recursive -w",
15 | "watch": "mocha --use_strict --recursive -w"
16 | },
17 | "repository": {
18 | "type": "git",
19 | "url": "git+https://github.com/pluralsight/mob-timer.git"
20 | },
21 | "author": "",
22 | "license": "ISC",
23 | "bugs": {
24 | "url": "https://github.com/pluralsight/mob-timer/issues"
25 | },
26 | "homepage": "https://github.com/pluralsight/mob-timer#readme",
27 | "devDependencies": {
28 | "electron": "^1.8.3",
29 | "electron-packager": "^12.1.0",
30 | "eslint": "^5.8.0",
31 | "eslint-config-standard": "^12.0.0",
32 | "eslint-plugin-import": "^2.14.0",
33 | "eslint-plugin-mocha": "^5.2.0",
34 | "eslint-plugin-node": "^8.0.0",
35 | "eslint-plugin-promise": "^4.0.1",
36 | "eslint-plugin-standard": "^4.0.0",
37 | "mocha": "^5.0.4",
38 | "sinon": "^7.1.1"
39 | },
40 | "dependencies": {
41 | "clipboardy": "^1.2.3",
42 | "uuid": "^3.2.1"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/state/mobbers.js:
--------------------------------------------------------------------------------
1 | const newGuid = require('uuid/v4')
2 |
3 | class Mobbers {
4 | constructor() {
5 | this.mobbers = []
6 | this.currentMobber = 0
7 | }
8 |
9 | getAll() {
10 | return this.mobbers
11 | }
12 |
13 | addMobber(mobber) {
14 | if (!mobber.id) {
15 | mobber.id = newGuid()
16 | }
17 | this.mobbers.push(mobber)
18 | }
19 |
20 | getActiveMobbers() {
21 | return this.mobbers.filter(m => !m.disabled)
22 | }
23 |
24 | getCurrentAndNextMobbers() {
25 | let active = this.getActiveMobbers()
26 | if (!active.length) {
27 | return { current: null, next: null }
28 | }
29 |
30 | return {
31 | current: active[this.currentMobber],
32 | next: active[(this.currentMobber + 1) % active.length]
33 | }
34 | }
35 |
36 | rotate() {
37 | let active = this.getActiveMobbers()
38 | this.currentMobber = active.length ? (this.currentMobber + 1) % active.length : 0
39 | }
40 |
41 | removeMobber(mobber) {
42 | this.mobbers = this.mobbers.filter(m => m.id !== mobber.id)
43 | if (this.currentMobber >= this.getActiveMobbers().length) {
44 | this.currentMobber = 0
45 | }
46 | }
47 |
48 | updateMobber(mobber) {
49 | let currentMobber = this.getActiveMobbers()[this.currentMobber]
50 | let index = this.mobbers.findIndex(m => m.id === mobber.id)
51 | if (index >= 0) {
52 | this.mobbers[index] = mobber
53 | let active = this.getActiveMobbers()
54 | if (currentMobber && currentMobber.id !== mobber.id) {
55 | this.currentMobber = active.findIndex(m => m.id === currentMobber.id)
56 | }
57 | this.currentMobber = active.length ? this.currentMobber % active.length : 0
58 | }
59 | }
60 |
61 | shuffleMobbers() {
62 | for (let i = this.mobbers.length - 1; i >= 0; i--) {
63 | const j = Math.floor(Math.random() * (i + 1));
64 | [this.mobbers[i], this.mobbers[j]] = [this.mobbers[j], this.mobbers[i]]
65 | }
66 | }
67 | }
68 |
69 | module.exports = Mobbers
70 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Pluralsight Mob Timer
2 | A cross-platform timer built on [Electron](http://electron.atom.io/)
3 | for doing [Mob Programming](http://mobprogramming.org/).
4 |
5 | **Heads up!** There is also an [extension](https://marketplace.visualstudio.com/items?itemName=pluralsight.live-share-mob-timer)
6 | for Visual Studio Code's Live Share experience.
7 | This is a different, standalone, project.
8 |
9 | 
10 |
11 | Click the gear icon in the top right to configure the timer.
12 | Then click the large circle to start/stop the timer,
13 | or the smaller circle to skip to the next mobber.
14 |
15 |
16 | # Build the timer
17 | Run `npm install` and then one of the following commands for your respective operating system:
18 | - Windows: `npm run build-win`
19 | - Mac OS X: `npm run build-mac`
20 | - Linux: `npm run build-linux` (You may need to install `libcanberra-gtk-module`)
21 |
22 | Platform specific packages will be placed in the `dist` directory.
23 | If you need a platform other than these, you will need to modify the build script in the `package.json` file.
24 |
25 |
26 | # Development
27 | Run `npm install` to get the dependencies, then `npm start` to run the timer.
28 | Run `npm test` to run the unit tests once, or alternatively `npm run watch` to run them on changes.
29 |
30 |
31 | # Motivation
32 | Pluralsight has a development team that does mob programming full-time,
33 | and a few other teams dabble in mobbing as well.
34 | We have tried and enjoyed a number of other mob timers, but we had various
35 | (mostly minor) gripes with them.
36 | So we decided to build one of our own.
37 |
38 | We had a few goals:
39 |
40 | * Make a timer that is hard to ignore, but also not overly annoying
41 | * Implement escalating alerts
42 | * Customization
43 | * Have a timer that we can easily hack on, built with tech we know
44 |
45 |
46 | # License
47 |
48 | The Pluralsight Mob Timer is licensed under the [Apache 2.0 license](LICENSE).
49 |
--------------------------------------------------------------------------------
/src/windows/config/index.css:
--------------------------------------------------------------------------------
1 | @import url('../theme.css');
2 |
3 | body {
4 | margin: 0;
5 | padding: 0;
6 | font-family: sans-serif;
7 | color: var(--main-text-color);
8 | background-color: var(--main-background-color);
9 | }
10 |
11 | input {
12 | height: 22px;
13 | }
14 |
15 | .container {
16 | margin: 20px;
17 | }
18 |
19 | .minutes {
20 | width: 40px;
21 | }
22 |
23 | .mobber {
24 | display: flex;
25 | padding: 4px;
26 | align-items: center;
27 | }
28 |
29 | .mobber .image {
30 | margin-right: 5px;
31 | height: 24px;
32 | width: 24px;
33 | border-radius: 50%;
34 | border: 2px solid var(--mobber-border-color);
35 | cursor: pointer;
36 | }
37 |
38 | .mobber .image:hover {
39 | border: 2px solid var(--mobber-border-highlight-color);
40 | }
41 |
42 | .mobber:nth-child(odd) {
43 | background-color: var(--table-stripe-color);
44 | }
45 |
46 | .mobber .btn:first-of-type {
47 | display: block;
48 | margin-left: auto;
49 | }
50 |
51 | .mobber.disabled .image {
52 | opacity: .3;
53 | }
54 |
55 | .mobber.disabled .name {
56 | font-style: italic;
57 | color: var(--disabled-text-color);
58 | text-decoration: line-through;
59 | }
60 |
61 | .oneLineInputAndButton {
62 | margin: 4px 0;
63 | }
64 |
65 | .addLabel {
66 | display: flex;
67 | align-items: center;
68 | }
69 |
70 | .mobberListActions {
71 | margin: 4px 0 4px -5px;
72 | display: flex;
73 | }
74 |
75 | .btn {
76 | background-color: var(--button-background-color);
77 | border: 0;
78 | border-radius: 3px;
79 | padding: 7px 10px;
80 | color: var(--button-text-color);
81 | margin-left: 5px;
82 | cursor: pointer;
83 | }
84 |
85 | h1 {
86 | font-size: 18px;
87 | border-bottom: 1px solid var(--main-text-color);
88 | margin-top: 20px;
89 | }
90 |
91 | input[type="checkbox" i] {
92 | position: relative;
93 | vertical-align: middle;
94 | }
95 |
96 | .settings > div {
97 | margin-bottom: 10px;
98 | }
99 |
100 | .fullscreen-seconds {
101 | width: 40px;
102 | }
103 |
104 | .replay-audio-seconds {
105 | width: 40px;
106 | }
107 |
108 | #replayAudioContainer.disabled {
109 | color: var(--disabled-text-color);
110 | }
111 |
112 | .custom-sound {
113 | width: 145px;
114 | }
115 |
116 | .clipboard-history-items {
117 | width: 40px;
118 | }
119 |
--------------------------------------------------------------------------------
/test/state/state-persister.specs.js:
--------------------------------------------------------------------------------
1 | const persister = require('../../src/state/state-persister')
2 | const sinon = require('sinon')
3 | const fs = require('fs')
4 | const assert = require('assert')
5 |
6 | describe('state-persister', () => {
7 | const sandbox = sinon.createSandbox()
8 |
9 | afterEach(() => sandbox.restore())
10 |
11 | describe('read', () => {
12 | const stateData = { some: 'state' }
13 | const oldStateData = { older: 'data' }
14 |
15 | beforeEach(() => {
16 | sandbox.stub(fs, 'readFileSync')
17 | .withArgs(persister.stateFile, 'utf-8').callsFake(() => JSON.stringify(stateData))
18 | .withArgs(persister.oldStateFile, 'utf-8').callsFake(() => JSON.stringify(oldStateData))
19 | })
20 |
21 | it('should return the contents of the state.json file', () => {
22 | sandbox.stub(fs, 'existsSync')
23 | .withArgs(persister.stateFile).callsFake(() => true)
24 |
25 | const result = persister.read()
26 | assert.deepStrictEqual(result, stateData)
27 | })
28 |
29 | it('should look for the old state file if the new one does not exist', () => {
30 | sandbox.stub(fs, 'existsSync')
31 | .withArgs(persister.stateFile).callsFake(() => false)
32 | .withArgs(persister.oldStateFile).callsFake(() => true)
33 |
34 | const result = persister.read()
35 | assert.deepStrictEqual(result, oldStateData)
36 | })
37 |
38 | it('should return an empty object if no state file exists', () => {
39 | sandbox.stub(fs, 'existsSync')
40 | .withArgs(persister.stateFile).callsFake(() => false)
41 | .withArgs(persister.oldStateFile).callsFake(() => false)
42 |
43 | const result = persister.read()
44 | assert.deepStrictEqual(result, {})
45 | })
46 | })
47 |
48 | describe('write', () => {
49 | const stateToWrite = { state: 'new' }
50 |
51 | beforeEach(() => {
52 | sandbox.stub(fs, 'writeFileSync')
53 | sandbox.stub(fs, 'mkdirSync')
54 | })
55 |
56 | it('should write the state to the file', () => {
57 | sandbox.stub(fs, 'existsSync')
58 | .withArgs(persister.mobTimerDir).callsFake(() => true)
59 |
60 | persister.write(stateToWrite)
61 |
62 | sinon.assert.notCalled(fs.mkdirSync)
63 | sinon.assert.calledWith(fs.writeFileSync, persister.stateFile, JSON.stringify(stateToWrite))
64 | })
65 |
66 | it('should create the directory if needed', () => {
67 | sandbox.stub(fs, 'existsSync')
68 | .withArgs(persister.mobTimerDir).callsFake(() => false)
69 |
70 | persister.write(stateToWrite)
71 |
72 | sinon.assert.calledWith(fs.mkdirSync, persister.mobTimerDir)
73 | sinon.assert.calledWith(fs.writeFileSync, persister.stateFile, JSON.stringify(stateToWrite))
74 | })
75 | })
76 | })
77 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | const electron = require('electron')
2 | const { app, ipcMain: ipc } = electron
3 |
4 | let windows = require('./windows/windows')
5 | let TimerState = require('./state/timer-state')
6 | let statePersister = require('./state/state-persister')
7 |
8 | let timerState = new TimerState()
9 |
10 | app.on('ready', () => {
11 | timerState.setCallback(onTimerEvent)
12 | timerState.loadState(statePersister.read())
13 | windows.setConfigState(timerState.getState())
14 | windows.createTimerWindow()
15 | if (timerState.getState().shuffleMobbersOnStartup) {
16 | timerState.shuffleMobbers()
17 | }
18 | })
19 |
20 | function onTimerEvent(event, data) {
21 | windows.dispatchEvent(event, data)
22 | if (event === 'configUpdated') {
23 | statePersister.write(timerState.getState())
24 | }
25 | }
26 |
27 | ipc.on('timerWindowReady', () => timerState.initialize())
28 | ipc.on('configWindowReady', () => timerState.publishConfig())
29 | ipc.on('fullscreenWindowReady', () => timerState.publishConfig())
30 |
31 | ipc.on('pause', () => timerState.pause())
32 | ipc.on('unpause', () => timerState.start())
33 | ipc.on('skip', () => timerState.rotate())
34 | ipc.on('startTurn', () => timerState.start())
35 | ipc.on('configure', () => {
36 | windows.showConfigWindow()
37 | windows.closeFullscreenWindow()
38 | })
39 |
40 | ipc.on('shuffleMobbers', () => timerState.shuffleMobbers())
41 | ipc.on('addMobber', (event, mobber) => timerState.addMobber(mobber))
42 | ipc.on('removeMobber', (event, mobber) => timerState.removeMobber(mobber))
43 | ipc.on('updateMobber', (event, mobber) => timerState.updateMobber(mobber))
44 | ipc.on('setSecondsPerTurn', (event, secondsPerTurn) => timerState.setSecondsPerTurn(secondsPerTurn))
45 | ipc.on('setSecondsUntilFullscreen', (event, secondsUntilFullscreen) => timerState.setSecondsUntilFullscreen(secondsUntilFullscreen))
46 | ipc.on('setSnapThreshold', (event, threshold) => timerState.setSnapThreshold(threshold))
47 | ipc.on('setAlertSoundTimes', (event, alertSoundTimes) => timerState.setAlertSoundTimes(alertSoundTimes))
48 | ipc.on('setAlertSound', (event, alertSound) => timerState.setAlertSound(alertSound))
49 | ipc.on('setTimerAlwaysOnTop', (event, value) => timerState.setTimerAlwaysOnTop(value))
50 | ipc.on('setShuffleMobbersOnStartup', (event, value) => timerState.setShuffleMobbersOnStartup(value))
51 | ipc.on('setClearClipboardHistoryOnTurnEnd', (event, value) => timerState.setClearClipboardHistoryOnTurnEnd(value))
52 | ipc.on('setNumberOfItemsClipboardHistoryStores', (event, value) => timerState.setNumberOfItemsClipboardHistoryStores(value))
53 |
54 | app.on('window-all-closed', function() {
55 | if (process.platform !== 'darwin') {
56 | app.quit()
57 | }
58 | })
59 |
60 | app.on('activate', function() {
61 | windows.createTimerWindow()
62 | })
63 |
--------------------------------------------------------------------------------
/test/state/timer.specs.js:
--------------------------------------------------------------------------------
1 | let Timer = require('../../src/state/timer')
2 | let assert = require('assert')
3 | const sinon = require('sinon')
4 |
5 | describe('Timer', () => {
6 | let timer
7 | let timerOptions
8 | let callbacks
9 | let clock
10 |
11 | const mockDateNow = () => {
12 | let calls = 0
13 | return () => { calls++; return calls * 1000 }
14 | }
15 |
16 | let createTimer = () => {
17 | timer = new Timer(timerOptions, x => callbacks.push(x))
18 | }
19 |
20 | beforeEach(() => {
21 | callbacks = []
22 | timerOptions = { rateMilliseconds: 20, time: 50, countDown: true }
23 | createTimer()
24 | clock = sinon.useFakeTimers()
25 | })
26 |
27 | afterEach(() => {
28 | timer.pause()
29 | clock.restore()
30 | })
31 |
32 | describe('on construction', () => {
33 | describe('with specified options', () => {
34 | it('should have the specified rateMilliseconds value', () => {
35 | assert.strictEqual(timer.rateMilliseconds, timerOptions.rateMilliseconds)
36 | })
37 |
38 | it('should have the specified value', () => {
39 | assert.strictEqual(timer.time, timerOptions.time)
40 | })
41 |
42 | it('should know if it is counting up or down based on the specified countDown', () => {
43 | assert.strictEqual(timer.countDown, true)
44 | })
45 | })
46 |
47 | describe('with default options', () => {
48 | beforeEach(() => {
49 | timerOptions = {}
50 | createTimer()
51 | })
52 |
53 | it('should have the default rateMilliseconds value', () => {
54 | assert.strictEqual(timer.rateMilliseconds, 1000)
55 | })
56 |
57 | it('should have the default time value', () => {
58 | assert.strictEqual(timer.time, 0)
59 | })
60 |
61 | it('should have the default countDown value', () => {
62 | assert.strictEqual(timer.countDown, false)
63 | })
64 | })
65 | })
66 |
67 | describe('start', () => {
68 | it('should generate callbacks when counting down', () => {
69 | timer.start(mockDateNow())
70 | clock.tick(50)
71 | assert.strictEqual(callbacks.join(','), '49,48')
72 | })
73 |
74 | it('should generate callbacks when counting up', () => {
75 | timerOptions.countDown = false
76 | createTimer()
77 | timer.start(mockDateNow())
78 | clock.tick(50)
79 | assert.strictEqual(callbacks.join(','), '51,52')
80 | })
81 | })
82 |
83 | describe('pause', () => {
84 | it('should stop further callbacks from occuring', () => {
85 | timer.start(mockDateNow())
86 | clock.tick(50)
87 | timer.pause()
88 | clock.tick(100)
89 | assert.strictEqual(callbacks.join(','), '49,48')
90 | })
91 | })
92 |
93 | describe('reset', () => {
94 | it('should set a new time value when the timer is not running', () => {
95 | timer.reset(42)
96 | assert.strictEqual(timer.time, 42)
97 | })
98 |
99 | it('should set a new time value when the timer is running', () => {
100 | const mockedNow = mockDateNow()
101 | timer.start(mockedNow)
102 | clock.tick(50)
103 | timer.reset(20, mockedNow)
104 | clock.tick(40)
105 | assert.strictEqual(callbacks.join(','), '49,48,19,18')
106 | })
107 | })
108 | })
109 |
--------------------------------------------------------------------------------
/src/windows/timer/index.css:
--------------------------------------------------------------------------------
1 | @import url('../theme.css');
2 |
3 | body {
4 | margin: 0;
5 | padding: 0;
6 | font-family: sans-serif;
7 | -webkit-app-region: drag;
8 | user-select: none;
9 | color: var(--main-text-color);
10 | background-color: var(--main-background-color);
11 | }
12 |
13 | .container {
14 | position: relative;
15 | height: 100vh;
16 | }
17 |
18 | .button {
19 | cursor: pointer;
20 | -webkit-app-region: no-drag;
21 | }
22 |
23 | .pic {
24 | border-radius: 50%;
25 | }
26 |
27 | .timerContainer {
28 | position: absolute;
29 | top: 5px;
30 | left: 5px;
31 | }
32 |
33 | .currentPic {
34 | position: absolute;
35 | top: 1px;
36 | left: 1px;
37 | width: 76px;
38 | height: 76px;
39 | }
40 |
41 | .timerCanvas {
42 | position: absolute;
43 | width: 80px;
44 | height: 80px;
45 | }
46 |
47 | .toggle {
48 | width: 80px;
49 | height: 80px;
50 | border-radius: 50%;
51 | }
52 |
53 | .overlay {
54 | opacity: 0;
55 | }
56 |
57 | .overlay:hover {
58 | opacity: .5;
59 | }
60 |
61 | .play {
62 | background-image: url(../img/play.png);
63 | background-size: 80px 80px;
64 | }
65 |
66 | .pause {
67 | background-image: url(../img/pause.png);
68 | background-size: 80px 80px;
69 | }
70 |
71 | .current {
72 | position: absolute;
73 | top: 25px;
74 | left: 90px;
75 | display: inline-block;
76 | font-size: 16px;
77 | white-space: nowrap;
78 | overflow: hidden;
79 | text-overflow: ellipsis;
80 | width: 120px;
81 | }
82 |
83 | .nextContainer {
84 | position: absolute;
85 | top: 50px;
86 | left: 90px;
87 | width: 100px;
88 | }
89 |
90 | .next {
91 | position: absolute;
92 | border-radius: 50%;
93 | width: 25px;
94 | height: 25px;
95 | border: 2px solid var(--mobber-border-color);
96 | }
97 |
98 | .overlay.next {
99 | background-image: url(../img/skip.png);
100 | background-size: 25px 25px;
101 | }
102 |
103 | .nextContainer span {
104 | position: absolute;
105 | top: 10px;
106 | left: 35px;
107 | font-size: 11px;
108 | white-space: nowrap;
109 | overflow: hidden;
110 | text-overflow: ellipsis;
111 | display: inline-block;
112 | width: 90px;
113 | }
114 |
115 | .configure {
116 | position: absolute;
117 | display: block;
118 | top: 5px;
119 | right: 5px;
120 | width: 20px;
121 | height: 20px;
122 | background-color: transparent;
123 | background-image: url(../img/configure.png);
124 | background-size: 20px 20px;
125 | border: 0;
126 | opacity: .5;
127 | }
128 |
129 | .configure:hover {
130 | opacity: .7;
131 | }
132 |
133 | @keyframes pulse {
134 | 0% {
135 | background: var(--timer-pulse-color-1);
136 | }
137 | 50% {
138 | background: var(--timer-pulse-color-2);
139 | }
140 | 100% {
141 | background: var(--timer-pulse-color-1);
142 | }
143 | }
144 |
145 | .isTurnEnded {
146 | animation-duration: 1s;
147 | animation-name: pulse;
148 | animation-iteration-count: infinite;
149 | color: var(--timer-pulse-text-color);
150 | }
151 |
152 | .isPaused {
153 | color: var(--timer-paused-text-color);
154 | background: var(--timer-paused-background-color);
155 | }
156 |
157 | audio {
158 | display: none;
159 | }
160 |
--------------------------------------------------------------------------------
/src/windows/config/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Mob Timer Configuration
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
Mobbers
18 |
19 |
25 |
26 |
27 |
28 |
29 |
85 |
86 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/src/windows/timer/index.js:
--------------------------------------------------------------------------------
1 | const theme = require('../theme.js')
2 |
3 | const ipc = require('electron').ipcRenderer
4 |
5 | const containerEl = document.getElementById('container')
6 | const toggleBtn = document.getElementById('toggleButton')
7 | const configureBtn = document.getElementById('configureButton')
8 | const currentEl = document.getElementById('current')
9 | const nextEl = document.getElementById('next')
10 | const currentPicEl = document.getElementById('currentPic')
11 | const nextPicEl = document.getElementById('nextPic')
12 | const nextBtn = document.getElementById('nextButton')
13 | const timerCanvas = document.getElementById('timerCanvas')
14 | const alertAudio = document.getElementById('alertAudio')
15 |
16 | const context = timerCanvas.getContext('2d')
17 |
18 | let paused = true
19 | let alertSoundTimes = []
20 |
21 | ipc.on('timerChange', (event, data) => {
22 | clearCanvas()
23 | drawTimerCircle()
24 | drawTimerArc(data.secondsRemaining, data.secondsPerTurn)
25 | })
26 |
27 | function clearCanvas() {
28 | context.clearRect(0, 0, timerCanvas.width, timerCanvas.height)
29 | }
30 |
31 | function drawTimerCircle() {
32 | const begin = 0
33 | const end = 2 * Math.PI
34 | drawArc(begin, end, '#EEEEEE')
35 | }
36 |
37 | function drawArc(begin, end, color) {
38 | const circleCenterX = timerCanvas.width / 2
39 | const circleCenterY = circleCenterX
40 | const circleRadius = circleCenterX - 6
41 | context.beginPath()
42 | context.arc(circleCenterX, circleCenterY, circleRadius, begin, end)
43 | context.strokeStyle = color
44 | context.lineWidth = 10
45 | context.stroke()
46 | }
47 |
48 | function drawTimerArc(seconds, maxSeconds) {
49 | let percent = 1 - (seconds / maxSeconds)
50 | if (percent === 0) {
51 | return
52 | }
53 | let begin = -(0.5 * Math.PI)
54 | let end = begin + (2 * Math.PI * percent)
55 | drawArc(begin, end, theme.mobberBorderHighlightColor)
56 | }
57 |
58 | ipc.on('rotated', (event, data) => {
59 | if (!data.current) {
60 | data.current = { name: 'Add a mobber' }
61 | }
62 | currentPicEl.src = data.current.image || '../img/sad-cyclops.png'
63 | currentEl.innerHTML = data.current.name
64 |
65 | if (!data.next) {
66 | data.next = data.current
67 | }
68 | nextPicEl.src = data.next.image || '../img/sad-cyclops.png'
69 | nextEl.innerHTML = data.next.name
70 | })
71 |
72 | ipc.on('paused', () => {
73 | paused = true
74 | containerEl.classList.add('isPaused')
75 | toggleBtn.classList.add('play')
76 | toggleBtn.classList.remove('pause')
77 | })
78 |
79 | ipc.on('started', () => {
80 | paused = false
81 | containerEl.classList.remove('isPaused')
82 | containerEl.classList.remove('isTurnEnded')
83 | toggleBtn.classList.remove('play')
84 | toggleBtn.classList.add('pause')
85 | })
86 |
87 | ipc.on('turnEnded', () => {
88 | paused = true
89 | containerEl.classList.remove('isPaused')
90 | containerEl.classList.add('isTurnEnded')
91 | toggleBtn.classList.add('play')
92 | toggleBtn.classList.remove('pause')
93 | })
94 |
95 | ipc.on('configUpdated', (event, data) => {
96 | alertSoundTimes = data.alertSoundTimes
97 | alertAudio.src = data.alertSound || './default.mp3'
98 | })
99 |
100 | ipc.on('alert', (event, data) => {
101 | if (alertSoundTimes.some(item => item === data)) {
102 | alertAudio.currentTime = 0
103 | alertAudio.play()
104 | }
105 | })
106 |
107 | ipc.on('stopAlerts', () => {
108 | alertAudio.pause()
109 | })
110 |
111 | toggleBtn.addEventListener('click', () => {
112 | paused ? ipc.send('unpause') : ipc.send('pause')
113 | })
114 | nextBtn.addEventListener('click', () => ipc.send('skip'))
115 | configureBtn.addEventListener('click', () => ipc.send('configure'))
116 |
117 | ipc.send('timerWindowReady')
118 |
--------------------------------------------------------------------------------
/src/windows/windows.js:
--------------------------------------------------------------------------------
1 | const electron = require('electron')
2 | const { app } = electron
3 | const windowSnapper = require('./window-snapper')
4 | const path = require('path')
5 |
6 | let timerWindow, configWindow, fullscreenWindow
7 | let snapThreshold, secondsUntilFullscreen, timerAlwaysOnTop
8 |
9 | exports.createTimerWindow = () => {
10 | if (timerWindow) {
11 | return
12 | }
13 |
14 | let { width, height } = electron.screen.getPrimaryDisplay().workAreaSize
15 | timerWindow = new electron.BrowserWindow({
16 | x: width - 220,
17 | y: height - 90,
18 | width: 220,
19 | height: 90,
20 | resizable: false,
21 | alwaysOnTop: timerAlwaysOnTop,
22 | frame: false,
23 | icon: path.join(__dirname, '/../../src/windows/img/icon.png')
24 | })
25 |
26 | timerWindow.loadURL(`file://${__dirname}/timer/index.html`)
27 | timerWindow.on('closed', () => (timerWindow = null))
28 |
29 | timerWindow.on('move', () => {
30 | if (snapThreshold <= 0) {
31 | return
32 | }
33 |
34 | let getCenter = bounds => {
35 | return {
36 | x: bounds.x + (bounds.width / 2),
37 | y: bounds.y + (bounds.height / 2)
38 | }
39 | }
40 |
41 | let windowBounds = timerWindow.getBounds()
42 | let screenBounds = electron.screen.getDisplayNearestPoint(getCenter(windowBounds)).workArea
43 |
44 | let snapTo = windowSnapper(windowBounds, screenBounds, snapThreshold)
45 | if (snapTo.x !== windowBounds.x || snapTo.y !== windowBounds.y) {
46 | timerWindow.setPosition(snapTo.x, snapTo.y)
47 | }
48 | })
49 | }
50 |
51 | exports.showConfigWindow = () => {
52 | if (configWindow) {
53 | configWindow.show()
54 | return
55 | }
56 | exports.createConfigWindow()
57 | }
58 |
59 | exports.createConfigWindow = () => {
60 | if (configWindow) {
61 | return
62 | }
63 |
64 | configWindow = new electron.BrowserWindow({
65 | width: 420,
66 | height: 500,
67 | autoHideMenuBar: true
68 | })
69 |
70 | configWindow.loadURL(`file://${__dirname}/config/index.html`)
71 | configWindow.on('closed', () => (configWindow = null))
72 | }
73 |
74 | exports.createFullscreenWindow = () => {
75 | if (fullscreenWindow) {
76 | return
77 | }
78 |
79 | const { width, height } = electron.screen.getPrimaryDisplay().workAreaSize
80 | fullscreenWindow = createAlwaysOnTopFullscreenInterruptingWindow({
81 | width,
82 | height,
83 | resizable: false,
84 | frame: false
85 | })
86 |
87 | fullscreenWindow.loadURL(`file://${__dirname}/fullscreen/index.html`)
88 | fullscreenWindow.on('closed', () => (fullscreenWindow = null))
89 | }
90 |
91 | exports.closeFullscreenWindow = () => {
92 | if (fullscreenWindow) {
93 | fullscreenWindow.close()
94 | }
95 | }
96 |
97 | exports.dispatchEvent = (event, data) => {
98 | if (event === 'configUpdated') {
99 | exports.setConfigState(data)
100 | }
101 | if (event === 'alert' && data === secondsUntilFullscreen) {
102 | exports.createFullscreenWindow()
103 | }
104 | if (event === 'stopAlerts') {
105 | exports.closeFullscreenWindow()
106 | }
107 |
108 | if (timerWindow) {
109 | timerWindow.webContents.send(event, data)
110 | }
111 | if (configWindow) {
112 | configWindow.webContents.send(event, data)
113 | }
114 | if (fullscreenWindow) {
115 | fullscreenWindow.webContents.send(event, data)
116 | }
117 | }
118 |
119 | exports.setConfigState = data => {
120 | var needToRecreateTimerWindow = timerAlwaysOnTop !== data.timerAlwaysOnTop
121 |
122 | snapThreshold = data.snapThreshold
123 | secondsUntilFullscreen = data.secondsUntilFullscreen
124 | timerAlwaysOnTop = data.timerAlwaysOnTop
125 |
126 | if (needToRecreateTimerWindow && timerWindow) {
127 | timerWindow.close()
128 | exports.createTimerWindow()
129 | }
130 | }
131 |
132 | function createAlwaysOnTopFullscreenInterruptingWindow(options) {
133 | return whileAppDockHidden(() => {
134 | const window = new electron.BrowserWindow(options)
135 | window.setAlwaysOnTop(true, 'screen-saver')
136 | return window
137 | })
138 | }
139 |
140 | function whileAppDockHidden(work) {
141 | if (app.dock) {
142 | // Mac OS: The window will be able to float above fullscreen windows too
143 | app.dock.hide()
144 | }
145 | const result = work()
146 | if (app.dock) {
147 | // Mac OS: Show in dock again, window has been created
148 | app.dock.show()
149 | }
150 | return result
151 | }
152 |
--------------------------------------------------------------------------------
/src/state/timer-state.js:
--------------------------------------------------------------------------------
1 | const Timer = require('./timer')
2 | const Mobbers = require('./mobbers')
3 | const clipboard = require('../clipboard')
4 |
5 | class TimerState {
6 | constructor(options) {
7 | if (!options) {
8 | options = {}
9 | }
10 | this.secondsPerTurn = 600
11 | this.mobbers = new Mobbers()
12 | this.secondsUntilFullscreen = 30
13 | this.snapThreshold = 25
14 | this.alertSound = null
15 | this.alertSoundTimes = []
16 | this.timerAlwaysOnTop = true
17 | this.shuffleMobbersOnStartup = false
18 | this.clearClipboardHistoryOnTurnEnd = false
19 | this.numberOfItemsClipboardHistoryStores = 25
20 |
21 | this.createTimers(options.Timer || Timer)
22 | }
23 |
24 | setCallback(callback) {
25 | this.callback = callback
26 | }
27 |
28 | createTimers(TimerClass) {
29 | this.mainTimer = new TimerClass({ countDown: true, time: this.secondsPerTurn }, secondsRemaining => {
30 | this.dispatchTimerChange(secondsRemaining)
31 | if (secondsRemaining < 0) {
32 | this.pause()
33 | this.rotate()
34 | this.callback('turnEnded')
35 | this.startAlerts()
36 |
37 | if (this.clearClipboardHistoryOnTurnEnd) {
38 | clipboard.clearClipboardHistory(this.numberOfItemsClipboardHistoryStores)
39 | }
40 | }
41 | })
42 |
43 | this.alertsTimer = new TimerClass({ countDown: false }, alertSeconds => {
44 | this.callback('alert', alertSeconds)
45 | })
46 | }
47 |
48 | dispatchTimerChange(secondsRemaining) {
49 | this.callback('timerChange', {
50 | secondsRemaining: secondsRemaining < 0 ? 0 : secondsRemaining,
51 | secondsPerTurn: this.secondsPerTurn
52 | })
53 | }
54 |
55 | reset() {
56 | this.mainTimer.reset(this.secondsPerTurn)
57 | this.dispatchTimerChange(this.secondsPerTurn)
58 | }
59 |
60 | startAlerts() {
61 | this.alertsTimer.reset(0)
62 | this.alertsTimer.start()
63 | this.callback('alert', 0)
64 | }
65 |
66 | stopAlerts() {
67 | this.alertsTimer.pause()
68 | this.callback('stopAlerts')
69 | }
70 |
71 | start() {
72 | this.mainTimer.start()
73 | this.callback('started')
74 | this.stopAlerts()
75 | }
76 |
77 | pause() {
78 | this.mainTimer.pause()
79 | this.callback('paused')
80 | this.stopAlerts()
81 | }
82 |
83 | rotate() {
84 | this.reset()
85 | this.mobbers.rotate()
86 | this.callback('rotated', this.mobbers.getCurrentAndNextMobbers())
87 | }
88 |
89 | initialize() {
90 | this.rotate()
91 | this.callback('turnEnded')
92 | this.publishConfig()
93 | }
94 |
95 | publishConfig() {
96 | this.callback('configUpdated', this.getState())
97 | this.callback('rotated', this.mobbers.getCurrentAndNextMobbers())
98 | }
99 |
100 | addMobber(mobber) {
101 | this.mobbers.addMobber(mobber)
102 | this.publishConfig()
103 | }
104 |
105 | removeMobber(mobber) {
106 | let currentMobber = this.mobbers.getCurrentAndNextMobbers().current
107 | let isRemovingCurrentMobber = currentMobber ? currentMobber.name === mobber.name : false
108 |
109 | this.mobbers.removeMobber(mobber)
110 |
111 | if (isRemovingCurrentMobber) {
112 | this.pause()
113 | this.reset()
114 | this.callback('turnEnded')
115 | }
116 |
117 | this.publishConfig()
118 | }
119 |
120 | updateMobber(mobber) {
121 | const currentMobber = this.mobbers.getCurrentAndNextMobbers().current
122 | const disablingCurrentMobber = (currentMobber && currentMobber.id === mobber.id && mobber.disabled)
123 |
124 | this.mobbers.updateMobber(mobber)
125 |
126 | if (disablingCurrentMobber) {
127 | this.pause()
128 | this.reset()
129 | this.callback('turnEnded')
130 | }
131 |
132 | this.publishConfig()
133 | }
134 |
135 | setSecondsPerTurn(value) {
136 | this.secondsPerTurn = value
137 | this.publishConfig()
138 | this.reset()
139 | }
140 |
141 | setSecondsUntilFullscreen(value) {
142 | this.secondsUntilFullscreen = value
143 | this.publishConfig()
144 | }
145 |
146 | setSnapThreshold(value) {
147 | this.snapThreshold = value
148 | this.publishConfig()
149 | }
150 |
151 | setAlertSound(soundFile) {
152 | this.alertSound = soundFile
153 | this.publishConfig()
154 | }
155 |
156 | setAlertSoundTimes(secondsArray) {
157 | this.alertSoundTimes = secondsArray
158 | this.publishConfig()
159 | }
160 |
161 | setTimerAlwaysOnTop(value) {
162 | this.timerAlwaysOnTop = value
163 | this.publishConfig()
164 | }
165 |
166 | setShuffleMobbersOnStartup(value) {
167 | this.shuffleMobbersOnStartup = value
168 | this.publishConfig()
169 | }
170 |
171 | shuffleMobbers() {
172 | this.mobbers.shuffleMobbers()
173 | this.publishConfig()
174 | }
175 |
176 | setClearClipboardHistoryOnTurnEnd(value) {
177 | this.clearClipboardHistoryOnTurnEnd = value
178 | this.publishConfig()
179 | }
180 |
181 | setNumberOfItemsClipboardHistoryStores(value) {
182 | this.numberOfItemsClipboardHistoryStores = value
183 | this.publishConfig()
184 | }
185 |
186 | getState() {
187 | return {
188 | mobbers: this.mobbers.getAll(),
189 | secondsPerTurn: this.secondsPerTurn,
190 | secondsUntilFullscreen: this.secondsUntilFullscreen,
191 | snapThreshold: this.snapThreshold,
192 | alertSound: this.alertSound,
193 | alertSoundTimes: this.alertSoundTimes,
194 | timerAlwaysOnTop: this.timerAlwaysOnTop,
195 | shuffleMobbersOnStartup: this.shuffleMobbersOnStartup,
196 | clearClipboardHistoryOnTurnEnd: this.clearClipboardHistoryOnTurnEnd,
197 | numberOfItemsClipboardHistoryStores: this.numberOfItemsClipboardHistoryStores
198 | }
199 | }
200 |
201 | loadState(state) {
202 | if (state.mobbers) {
203 | state.mobbers.forEach(x => this.addMobber(x))
204 | }
205 |
206 | this.setSecondsPerTurn(state.secondsPerTurn || this.secondsPerTurn)
207 | if (typeof state.secondsUntilFullscreen === 'number') {
208 | this.setSecondsUntilFullscreen(state.secondsUntilFullscreen)
209 | }
210 | if (typeof state.snapThreshold === 'number') {
211 | this.setSnapThreshold(state.snapThreshold)
212 | }
213 | this.alertSound = state.alertSound || null
214 | this.alertSoundTimes = state.alertSoundTimes || []
215 | if (typeof state.timerAlwaysOnTop === 'boolean') {
216 | this.timerAlwaysOnTop = state.timerAlwaysOnTop
217 | }
218 | this.shuffleMobbersOnStartup = !!state.shuffleMobbersOnStartup
219 | this.clearClipboardHistoryOnTurnEnd = !!state.clearClipboardHistoryOnTurnEnd
220 | this.numberOfItemsClipboardHistoryStores = Math.floor(state.numberOfItemsClipboardHistoryStores) > 0 ? Math.floor(state.numberOfItemsClipboardHistoryStores) : 1
221 | }
222 | }
223 |
224 | module.exports = TimerState
225 |
--------------------------------------------------------------------------------
/src/windows/config/index.js:
--------------------------------------------------------------------------------
1 | const ipc = require('electron').ipcRenderer
2 | const { dialog } = require('electron').remote
3 |
4 | const mobbersEl = document.getElementById('mobbers')
5 | const shuffleEl = document.getElementById('shuffle')
6 | const minutesEl = document.getElementById('minutes')
7 | const addEl = document.getElementById('add')
8 | const addMobberForm = document.getElementById('addMobberForm')
9 | const fullscreenSecondsEl = document.getElementById('fullscreen-seconds')
10 | const snapToEdgesCheckbox = document.getElementById('snap-to-edges')
11 | const alertAudioCheckbox = document.getElementById('alertAudio')
12 | const replayAudioContainer = document.getElementById('replayAudioContainer')
13 | const replayAlertAudioCheckbox = document.getElementById('replayAlertAudio')
14 | const replayAudioAfterSeconds = document.getElementById('replayAudioAfterSeconds')
15 | const useCustomSoundCheckbox = document.getElementById('useCustomSound')
16 | const customSoundEl = document.getElementById('customSound')
17 | const timerAlwaysOnTopCheckbox = document.getElementById('timerAlwaysOnTop')
18 | const shuffleMobbersOnStartupCheckbox = document.getElementById('shuffleMobbersOnStartup')
19 | const clearClipboardHistoryOnTurnEndCheckbox = document.getElementById('clearClipboardHistoryOnTurnEnd')
20 | const numberOfItemsClipboardHistoryStores = document.getElementById('numberOfItemsClipboardHistoryStores')
21 |
22 | function createMobberEl(mobber) {
23 | const el = document.createElement('div')
24 | el.classList.add('mobber')
25 | if (mobber.disabled) {
26 | el.classList.add('disabled')
27 | }
28 |
29 | const imgEl = document.createElement('img')
30 | imgEl.src = mobber.image || '../img/sad-cyclops.png'
31 | imgEl.classList.add('image')
32 | el.appendChild(imgEl)
33 |
34 | const nameEl = document.createElement('div')
35 | nameEl.innerHTML = mobber.name
36 | nameEl.classList.add('name')
37 | el.appendChild(nameEl)
38 |
39 | const disableBtn = document.createElement('button')
40 | disableBtn.classList.add('btn')
41 | disableBtn.innerHTML = mobber.disabled ? 'Enable' : 'Disable'
42 | el.appendChild(disableBtn)
43 |
44 | const rmBtn = document.createElement('button')
45 | rmBtn.classList.add('btn')
46 | rmBtn.innerHTML = 'Remove'
47 | el.appendChild(rmBtn)
48 |
49 | imgEl.addEventListener('click', () => selectImage(mobber))
50 | disableBtn.addEventListener('click', () => toggleMobberDisabled(mobber))
51 | rmBtn.addEventListener('click', () => ipc.send('removeMobber', mobber))
52 |
53 | return el
54 | }
55 |
56 | function selectImage(mobber) {
57 | var image = dialog.showOpenDialog({
58 | title: 'Select image',
59 | filters: [
60 | { name: 'Images', extensions: ['jpg', 'png', 'gif'] }
61 | ],
62 | properties: ['openFile']
63 | })
64 |
65 | if (image) {
66 | mobber.image = image[0]
67 | ipc.send('updateMobber', mobber)
68 | }
69 | }
70 |
71 | function toggleMobberDisabled(mobber) {
72 | mobber.disabled = !mobber.disabled
73 | ipc.send('updateMobber', mobber)
74 | }
75 |
76 | ipc.on('configUpdated', (event, data) => {
77 | minutesEl.value = Math.ceil(data.secondsPerTurn / 60)
78 | mobbersEl.innerHTML = ''
79 | const frag = document.createDocumentFragment()
80 | data.mobbers.map(mobber => {
81 | frag.appendChild(createMobberEl(mobber))
82 | })
83 | mobbersEl.appendChild(frag)
84 | fullscreenSecondsEl.value = data.secondsUntilFullscreen
85 | snapToEdgesCheckbox.checked = data.snapThreshold > 0
86 |
87 | alertAudioCheckbox.checked = data.alertSoundTimes.length > 0
88 | replayAlertAudioCheckbox.checked = data.alertSoundTimes.length > 1
89 | replayAudioAfterSeconds.value = data.alertSoundTimes.length > 1 ? data.alertSoundTimes[1] : 30
90 | updateAlertControls()
91 |
92 | useCustomSoundCheckbox.checked = !!data.alertSound
93 | customSoundEl.value = data.alertSound
94 |
95 | timerAlwaysOnTopCheckbox.checked = data.timerAlwaysOnTop
96 | shuffleMobbersOnStartupCheckbox.checked = data.shuffleMobbersOnStartup
97 | clearClipboardHistoryOnTurnEndCheckbox.checked = data.clearClipboardHistoryOnTurnEnd
98 | numberOfItemsClipboardHistoryStores.value = data.numberOfItemsClipboardHistoryStores
99 | numberOfItemsClipboardHistoryStores.disabled = !clearClipboardHistoryOnTurnEndCheckbox.checked
100 | })
101 |
102 | minutesEl.addEventListener('change', () => {
103 | ipc.send('setSecondsPerTurn', minutesEl.value * 60)
104 | })
105 |
106 | addMobberForm.addEventListener('submit', event => {
107 | event.preventDefault()
108 | let value = addEl.value.trim()
109 | if (!value) {
110 | return
111 | }
112 | ipc.send('addMobber', { name: value })
113 | addEl.value = ''
114 | })
115 |
116 | shuffleEl.addEventListener('click', event => {
117 | event.preventDefault()
118 | ipc.send('shuffleMobbers')
119 | })
120 |
121 | fullscreenSecondsEl.addEventListener('change', () => {
122 | ipc.send('setSecondsUntilFullscreen', fullscreenSecondsEl.value * 1)
123 | })
124 |
125 | ipc.send('configWindowReady')
126 |
127 | snapToEdgesCheckbox.addEventListener('change', () => {
128 | ipc.send('setSnapThreshold', snapToEdgesCheckbox.checked ? 25 : 0)
129 | })
130 |
131 | alertAudioCheckbox.addEventListener('change', () => updateAlertTimes())
132 | replayAlertAudioCheckbox.addEventListener('change', () => updateAlertTimes())
133 | replayAudioAfterSeconds.addEventListener('change', () => updateAlertTimes())
134 |
135 | function updateAlertTimes() {
136 | updateAlertControls()
137 |
138 | let alertSeconds = []
139 | if (alertAudioCheckbox.checked) {
140 | alertSeconds.push(0)
141 | if (replayAlertAudioCheckbox.checked) {
142 | alertSeconds.push(replayAudioAfterSeconds.value * 1)
143 | }
144 | }
145 |
146 | ipc.send('setAlertSoundTimes', alertSeconds)
147 | }
148 |
149 | function updateAlertControls() {
150 | let replayDisabled = !alertAudioCheckbox.checked
151 | replayAlertAudioCheckbox.disabled = replayDisabled
152 |
153 | if (replayDisabled) {
154 | replayAlertAudioCheckbox.checked = false
155 | replayAudioContainer.classList.add('disabled')
156 | } else {
157 | replayAudioContainer.classList.remove('disabled')
158 | }
159 |
160 | let secondsDisabled = !replayAlertAudioCheckbox.checked
161 | replayAudioAfterSeconds.disabled = secondsDisabled
162 | }
163 |
164 | useCustomSoundCheckbox.addEventListener('change', () => {
165 | let mp3 = null
166 |
167 | if (useCustomSoundCheckbox.checked) {
168 | const selectedMp3 = dialog.showOpenDialog({
169 | title: 'Select alert sound',
170 | filters: [
171 | { name: 'MP3', extensions: ['mp3'] }
172 | ],
173 | properties: ['openFile']
174 | })
175 |
176 | if (selectedMp3) {
177 | mp3 = selectedMp3[0]
178 | } else {
179 | useCustomSoundCheckbox.checked = false
180 | }
181 | }
182 |
183 | ipc.send('setAlertSound', mp3)
184 | })
185 |
186 | timerAlwaysOnTopCheckbox.addEventListener('change', () => {
187 | ipc.send('setTimerAlwaysOnTop', timerAlwaysOnTopCheckbox.checked)
188 | })
189 |
190 | shuffleMobbersOnStartupCheckbox.addEventListener('change', () => {
191 | ipc.send('setShuffleMobbersOnStartup', shuffleMobbersOnStartupCheckbox.checked)
192 | })
193 |
194 | clearClipboardHistoryOnTurnEndCheckbox.addEventListener('change', () => {
195 | numberOfItemsClipboardHistoryStores.disabled = !clearClipboardHistoryOnTurnEndCheckbox.checked
196 | ipc.send('setClearClipboardHistoryOnTurnEnd', clearClipboardHistoryOnTurnEndCheckbox.checked)
197 | })
198 |
199 | numberOfItemsClipboardHistoryStores.addEventListener('change', () => {
200 | ipc.send('setNumberOfItemsClipboardHistoryStores', Math.floor(numberOfItemsClipboardHistoryStores.value) > 0 ? Math.floor(numberOfItemsClipboardHistoryStores.value) : 1)
201 | })
202 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright {yyyy} {name of copyright owner}
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/test/state/mobbers.specs.js:
--------------------------------------------------------------------------------
1 | let Mobbers = require('../../src/state/mobbers')
2 | let assert = require('assert')
3 | const sinon = require('sinon')
4 |
5 | describe('Mobbers', () => {
6 | let mobbers
7 |
8 | beforeEach(() => {
9 | mobbers = new Mobbers()
10 | })
11 |
12 | describe('on construction', () => {
13 | it('should have no mobbers', () => {
14 | let result = mobbers.getAll()
15 | assert.deepStrictEqual(result, [])
16 | })
17 | })
18 |
19 | describe('shuffleMobbers', () => {
20 | let originalRandom
21 |
22 | beforeEach(() => {
23 | originalRandom = Math.random
24 | })
25 |
26 | afterEach(() => {
27 | Math.random = originalRandom
28 | })
29 |
30 | it('shuffles the mobbers so there is a different order', () => {
31 | Math.random = sinon.stub()
32 | Math.random.onCall(0).returns(0.3)
33 | Math.random.onCall(1).returns(0.5)
34 | Math.random.onCall(2).returns(0.7)
35 | Math.random.onCall(3).returns(0.9)
36 | Math.random.throws(new Error('No more random should be needed!'))
37 | mobbers.addMobber({ name: 'Testerson', id: 'mobber-1' })
38 | mobbers.addMobber({ name: 'TestersonFace', id: 'mobber-2' })
39 | mobbers.addMobber({ name: 'TestersonHead', id: 'mobber-3' })
40 | mobbers.addMobber({ name: 'TestersonNose', id: 'mobber-4' })
41 |
42 | mobbers.shuffleMobbers()
43 |
44 | const mobberIds = mobbers.getAll().map(mobber => mobber.id)
45 | assert.deepStrictEqual(mobberIds, [
46 | 'mobber-1',
47 | 'mobber-3',
48 | 'mobber-4',
49 | 'mobber-2'
50 | ])
51 | })
52 | })
53 |
54 | describe('addMobber', () => {
55 | it('should add a mobber', () => {
56 | mobbers.addMobber({ name: 'Test' })
57 | let result = mobbers.getAll()
58 | assert.strictEqual(result[0].name, 'Test')
59 | })
60 |
61 | it('should add an id to the mobber if missing', () => {
62 | mobbers.addMobber({ name: 'Test' })
63 | let result = mobbers.getAll()
64 | assert.notStrictEqual(result[0].id, undefined)
65 | })
66 |
67 | it('should NOT add an id to the mobber if it already has one', () => {
68 | mobbers.addMobber({ id: 'test-id', name: 'Test' })
69 | let result = mobbers.getAll()
70 | assert.strictEqual(result[0].id, 'test-id')
71 | })
72 |
73 | it('should always add to the end of the list', () => {
74 | mobbers.addMobber({ name: 'Test 1' })
75 | mobbers.addMobber({ name: 'Test 2' })
76 | let result = mobbers.getAll()
77 | assert.strictEqual(result[0].name, 'Test 1')
78 | assert.strictEqual(result[1].name, 'Test 2')
79 | })
80 | })
81 |
82 | describe('getCurrentAndNextMobbers', () => {
83 | it('return null values if there are no mobbers', () => {
84 | let result = mobbers.getCurrentAndNextMobbers()
85 | assert.deepStrictEqual(result, { current: null, next: null })
86 | })
87 |
88 | it('return the same mobber for current and next if there is only one mobber', () => {
89 | mobbers.addMobber({ name: 'Test' })
90 | let result = mobbers.getCurrentAndNextMobbers()
91 | assert.strictEqual(result.current.name, 'Test')
92 | assert.strictEqual(result.next.name, 'Test')
93 | })
94 |
95 | it('return the current and next mobber when there are 2 mobbers', () => {
96 | mobbers.addMobber({ name: 'Test 1' })
97 | mobbers.addMobber({ name: 'Test 2' })
98 | let result = mobbers.getCurrentAndNextMobbers()
99 | assert.strictEqual(result.current.name, 'Test 1')
100 | assert.strictEqual(result.next.name, 'Test 2')
101 | })
102 |
103 | it('should return the correct mobbers after rotating', () => {
104 | mobbers.addMobber({ name: 'Test 1' })
105 | mobbers.addMobber({ name: 'Test 2' })
106 | mobbers.addMobber({ name: 'Test 3' })
107 | mobbers.rotate()
108 | let result = mobbers.getCurrentAndNextMobbers()
109 | assert.strictEqual(result.current.name, 'Test 2')
110 | assert.strictEqual(result.next.name, 'Test 3')
111 | })
112 |
113 | it('should not include disabled mobbers', () => {
114 | mobbers.addMobber({ name: 'Test 1' })
115 | mobbers.addMobber({ name: 'Test 2', disabled: true })
116 | mobbers.addMobber({ name: 'Test 3' })
117 | let result = mobbers.getCurrentAndNextMobbers()
118 | assert.strictEqual(result.current.name, 'Test 1')
119 | assert.strictEqual(result.next.name, 'Test 3')
120 | })
121 | })
122 |
123 | describe('rotate', () => {
124 | it('should do nothing when there are no mobbers', () => {
125 | mobbers.rotate()
126 | let result = mobbers.getCurrentAndNextMobbers()
127 | assert.deepStrictEqual(result, { current: null, next: null })
128 | })
129 |
130 | it('should do nothing when there is only one mobber', () => {
131 | mobbers.addMobber({ name: 'Test' })
132 | mobbers.rotate()
133 | let result = mobbers.getCurrentAndNextMobbers()
134 | assert.strictEqual(result.current.name, 'Test')
135 | assert.strictEqual(result.next.name, 'Test')
136 | })
137 |
138 | it('should rotate the mobbers when there are 2', () => {
139 | mobbers.addMobber({ name: 'Test 1' })
140 | mobbers.addMobber({ name: 'Test 2' })
141 | mobbers.rotate()
142 | let result = mobbers.getCurrentAndNextMobbers()
143 | assert.strictEqual(result.current.name, 'Test 2')
144 | assert.strictEqual(result.next.name, 'Test 1')
145 | })
146 |
147 | it('should loop back around after the end of the list', () => {
148 | mobbers.addMobber({ name: 'Test 1' })
149 | mobbers.addMobber({ name: 'Test 2' })
150 | mobbers.rotate()
151 | mobbers.rotate()
152 | let result = mobbers.getCurrentAndNextMobbers()
153 | assert.strictEqual(result.current.name, 'Test 1')
154 | assert.strictEqual(result.next.name, 'Test 2')
155 | })
156 |
157 | it('should skip disabled mobbers', () => {
158 | mobbers.addMobber({ name: 'Test 1', disabled: true })
159 | mobbers.addMobber({ name: 'Test 2' })
160 | mobbers.addMobber({ name: 'Test 3', disabled: true })
161 | mobbers.addMobber({ name: 'Test 4', disabled: true })
162 | mobbers.addMobber({ name: 'Test 5', disabled: false })
163 | mobbers.rotate()
164 | mobbers.rotate()
165 | let result = mobbers.getCurrentAndNextMobbers()
166 | assert.strictEqual(result.current.name, 'Test 2')
167 | assert.strictEqual(result.next.name, 'Test 5')
168 | })
169 | })
170 |
171 | describe('removeMobber', () => {
172 | it('should not remove anyone if the id does not match', () => {
173 | mobbers.addMobber({ name: 'Test', id: 'test-id' })
174 | mobbers.removeMobber({ name: 'Other', id: 'other-id' })
175 | let result = mobbers.getAll()
176 | assert.strictEqual(result[0].name, 'Test')
177 | })
178 |
179 | it('should remove the mobber that matches by id', () => {
180 | mobbers.addMobber({ name: 'Test 1', id: '1a' })
181 | mobbers.addMobber({ name: 'Test 2', id: '2a' })
182 | mobbers.addMobber({ name: 'Test 1', id: '1b' })
183 | mobbers.addMobber({ name: 'Test 2', id: '2b' })
184 | mobbers.removeMobber({ name: 'Test 1', id: '1b' })
185 | let result = mobbers.getAll()
186 | assert.strictEqual(result.length, 3)
187 | assert.strictEqual(result[0].id, '1a')
188 | assert.strictEqual(result[1].id, '2a')
189 | assert.strictEqual(result[2].id, '2b')
190 | })
191 |
192 | it('should update correctly if the removed mobber was the current mobber', () => {
193 | mobbers.addMobber({ name: 'Test 1', id: 't1' })
194 | mobbers.addMobber({ name: 'Test 2', id: 't2' })
195 | mobbers.addMobber({ name: 'Test 3', id: 't3' })
196 | mobbers.rotate()
197 | mobbers.removeMobber({ id: 't2' })
198 | let result = mobbers.getCurrentAndNextMobbers()
199 | assert.strictEqual(result.current.name, 'Test 3')
200 | assert.strictEqual(result.next.name, 'Test 1')
201 | })
202 |
203 | it('should wrap around correctly if the removed mobber was current and at the end of the list', () => {
204 | mobbers.addMobber({ name: 'Test 1', id: 't1' })
205 | mobbers.addMobber({ name: 'Test 2', id: 't2' })
206 | mobbers.addMobber({ name: 'Test 3', id: 't3' })
207 | mobbers.rotate()
208 | mobbers.rotate()
209 | mobbers.removeMobber({ id: 't3' })
210 | let result = mobbers.getCurrentAndNextMobbers()
211 | assert.strictEqual(result.current.name, 'Test 1')
212 | assert.strictEqual(result.next.name, 'Test 2')
213 | })
214 |
215 | it('should wrap around correctly even if some mobbers are disabled', () => {
216 | mobbers.addMobber({ name: 'Test 1', id: 't1' })
217 | mobbers.addMobber({ name: 'Test 2', id: 't2', disabled: true })
218 | mobbers.addMobber({ name: 'Test 3', id: 't3' })
219 | mobbers.rotate()
220 | mobbers.removeMobber({ id: 't3' })
221 | let result = mobbers.getCurrentAndNextMobbers()
222 | assert.strictEqual(result.current.name, 'Test 1')
223 | assert.strictEqual(result.next.name, 'Test 1')
224 | })
225 | })
226 |
227 | describe('updateMobber', () => {
228 | it('should replace the mobber by matching id', () => {
229 | mobbers.addMobber({ name: 'Test 1', id: 't1' })
230 | mobbers.addMobber({ name: 'Test 2', id: 't2' })
231 | mobbers.addMobber({ name: 'Test 3', id: 't3' })
232 | mobbers.updateMobber({ name: 'Test 2-updated', id: 't2', image: 'image-path' })
233 | let result = mobbers.getAll()
234 | assert.strictEqual(result.length, 3)
235 | assert.strictEqual(result[0].name, 'Test 1')
236 | assert.strictEqual(result[1].name, 'Test 2-updated')
237 | assert.strictEqual(result[2].name, 'Test 3')
238 | assert.strictEqual(result[1].image, 'image-path')
239 | })
240 |
241 | it('should not replace anything if the id does not match', () => {
242 | mobbers.addMobber({ name: 'Test', id: 'test-id' })
243 | mobbers.updateMobber({ name: 'Tester', id: 'other-id', image: 'image-path' })
244 | let result = mobbers.getAll()
245 | assert.strictEqual(result.length, 1)
246 | assert.strictEqual(result[0].name, 'Test')
247 | assert.strictEqual(result[0].id, 'test-id')
248 | assert.strictEqual(result[0].image, undefined)
249 | })
250 |
251 | it('should not change the current mobber when enabling another', () => {
252 | mobbers.addMobber({ name: 'Test 1', id: 't1' })
253 | mobbers.addMobber({ name: 'Test 2', id: 't2', disabled: true })
254 | mobbers.addMobber({ name: 'Test 3', id: 't3' })
255 |
256 | mobbers.rotate()
257 | let result = mobbers.getCurrentAndNextMobbers()
258 | assert.strictEqual(result.current.name, 'Test 3')
259 | assert.strictEqual(result.next.name, 'Test 1')
260 |
261 | mobbers.updateMobber({ name: 'Test 2', id: 't2', disabled: false })
262 | result = mobbers.getCurrentAndNextMobbers()
263 | assert.strictEqual(result.current.name, 'Test 3')
264 | assert.strictEqual(result.next.name, 'Test 1')
265 | })
266 |
267 | it('should not change the current mobber when disabling another', () => {
268 | mobbers.addMobber({ name: 'Test 1', id: 't1' })
269 | mobbers.addMobber({ name: 'Test 2', id: 't2' })
270 | mobbers.addMobber({ name: 'Test 3', id: 't3' })
271 |
272 | mobbers.rotate()
273 | let result = mobbers.getCurrentAndNextMobbers()
274 | assert.strictEqual(result.current.name, 'Test 2')
275 | assert.strictEqual(result.next.name, 'Test 3')
276 |
277 | mobbers.updateMobber({ name: 'Test 1', id: 't1', disabled: true })
278 | result = mobbers.getCurrentAndNextMobbers()
279 | assert.strictEqual(result.current.name, 'Test 2')
280 | assert.strictEqual(result.next.name, 'Test 3')
281 | })
282 |
283 | it('should go to the next mobber when disabling the current mobber', () => {
284 | mobbers.addMobber({ name: 'Test 1', id: 't1' })
285 | mobbers.addMobber({ name: 'Test 2', id: 't2' })
286 | mobbers.addMobber({ name: 'Test 3', id: 't3' })
287 |
288 | let result = mobbers.getCurrentAndNextMobbers()
289 | assert.strictEqual(result.current.name, 'Test 1')
290 | assert.strictEqual(result.next.name, 'Test 2')
291 |
292 | mobbers.updateMobber({ name: 'Test 1', id: 't1', disabled: true })
293 | result = mobbers.getCurrentAndNextMobbers()
294 | assert.strictEqual(result.current.name, 'Test 2')
295 | assert.strictEqual(result.next.name, 'Test 3')
296 | })
297 |
298 | it('should wrap around to the first mobber when disabling the current last mobber', () => {
299 | mobbers.addMobber({ name: 'Test 1', id: 't1' })
300 | mobbers.addMobber({ name: 'Test 2', id: 't2' })
301 | mobbers.addMobber({ name: 'Test 3', id: 't3' })
302 |
303 | mobbers.rotate()
304 | mobbers.rotate()
305 | let result = mobbers.getCurrentAndNextMobbers()
306 | assert.strictEqual(result.current.name, 'Test 3')
307 | assert.strictEqual(result.next.name, 'Test 1')
308 |
309 | mobbers.updateMobber({ name: 'Test 3', id: 't3', disabled: true })
310 | result = mobbers.getCurrentAndNextMobbers()
311 | assert.strictEqual(result.current.name, 'Test 1')
312 | assert.strictEqual(result.next.name, 'Test 2')
313 | })
314 | })
315 | })
316 |
--------------------------------------------------------------------------------
/test/state/timer-state.specs.js:
--------------------------------------------------------------------------------
1 | let TimerState = require('../../src/state/timer-state')
2 | let TestTimer = require('./test-timer')
3 | let assert = require('assert')
4 |
5 | describe('timer-state', () => {
6 | let timerState
7 | let events
8 |
9 | let assertEvent = eventName => {
10 | var event = events.find(x => x.event === eventName)
11 | assert(event, eventName + ' event not found')
12 | return event
13 | }
14 |
15 | beforeEach(() => {
16 | events = []
17 | timerState = new TimerState({ Timer: TestTimer })
18 | timerState.setCallback((event, data) => {
19 | events.push({ event, data })
20 | })
21 | })
22 |
23 | describe('initialize', () => {
24 | beforeEach(() => timerState.initialize())
25 |
26 | it('should publish a timerChange event', () => {
27 | var event = assertEvent('timerChange')
28 | assert.deepStrictEqual(event.data, {
29 | secondsRemaining: 600,
30 | secondsPerTurn: 600
31 | })
32 | })
33 |
34 | it('should publish a rotated event', () => {
35 | var event = assertEvent('rotated')
36 | assert.deepStrictEqual(event.data, { current: null, next: null })
37 | })
38 |
39 | it('should publish a turnEnded event', () => {
40 | assertEvent('turnEnded')
41 | })
42 |
43 | it('should publish a configUpdated event', () => {
44 | assertEvent('configUpdated')
45 | })
46 | })
47 |
48 | describe('reset', () => {
49 | beforeEach(() => timerState.reset())
50 |
51 | it('should publish a timerChange event', () => {
52 | var event = assertEvent('timerChange')
53 | assert.deepStrictEqual(event.data, {
54 | secondsRemaining: 600,
55 | secondsPerTurn: 600
56 | })
57 | })
58 | })
59 |
60 | describe('start', () => {
61 | beforeEach(() => timerState.start())
62 |
63 | it('should start the mainTimer', function() {
64 | assert.strictEqual(timerState.mainTimer.isRunning, true)
65 | })
66 |
67 | it('should publish a started event', () => {
68 | assertEvent('started')
69 | })
70 |
71 | it('should publish a stopAlerts event', () => {
72 | assertEvent('stopAlerts')
73 | })
74 |
75 | it('should publish a timerChange event when the timer calls back', () => {
76 | timerState.mainTimer.callback(599)
77 | var event = assertEvent('timerChange')
78 | assert.deepStrictEqual(event.data, {
79 | secondsRemaining: 599,
80 | secondsPerTurn: 600
81 | })
82 | })
83 |
84 | it('should publish events when the time is up', () => {
85 | timerState.mainTimer.callback(-1)
86 | assertEvent('turnEnded')
87 | assertEvent('paused')
88 | assertEvent('rotated')
89 | var alertEvent = assertEvent('alert')
90 | assert.strictEqual(alertEvent.data, 0)
91 | })
92 |
93 | it('should start the alertsTimer after the timer is up', () => {
94 | assert.strictEqual(timerState.alertsTimer.isRunning, false)
95 | timerState.mainTimer.callback(-1)
96 | assert.strictEqual(timerState.alertsTimer.isRunning, true)
97 | })
98 |
99 | it('should publish alert events after the time is up', () => {
100 | timerState.alertsTimer.callback(1)
101 | var event = assertEvent('alert')
102 | assert.strictEqual(event.data, 1)
103 | })
104 | })
105 |
106 | describe('pause', () => {
107 | beforeEach(() => timerState.pause())
108 |
109 | it('should publish a paused event', () => {
110 | assertEvent('paused')
111 | })
112 |
113 | it('should publish a stopAlerts event', () => {
114 | assertEvent('stopAlerts')
115 | })
116 |
117 | it('should stop the mainTimer', () => {
118 | timerState.start()
119 | assert.strictEqual(timerState.mainTimer.isRunning, true)
120 |
121 | timerState.pause()
122 | assert.strictEqual(timerState.mainTimer.isRunning, false)
123 | })
124 | })
125 |
126 | describe('rotate', () => {
127 | beforeEach(() => {
128 | timerState.addMobber({ name: 'A' })
129 | timerState.addMobber({ name: 'B' })
130 | timerState.addMobber({ name: 'C' })
131 | events = []
132 | timerState.rotate()
133 | })
134 |
135 | it('should publish a rotated event', () => {
136 | var event = assertEvent('rotated')
137 | assert.strictEqual(event.data.current.name, 'B', 'expected B to be current')
138 | assert.strictEqual(event.data.next.name, 'C', 'expected C to be next')
139 | })
140 |
141 | it('should publish a timerChange event', () => {
142 | var event = assertEvent('timerChange')
143 | assert.deepStrictEqual(event.data, {
144 | secondsRemaining: 600,
145 | secondsPerTurn: 600
146 | })
147 | })
148 |
149 | it('should wrap around at the end of the list', () => {
150 | events = []
151 | timerState.rotate()
152 | var event = assertEvent('rotated')
153 | assert.strictEqual(event.data.current.name, 'C', 'expected C to be current')
154 | assert.strictEqual(event.data.next.name, 'A', 'expected A to be next')
155 | })
156 | })
157 |
158 | describe('publishConfig', () => {
159 | beforeEach(() => timerState.publishConfig())
160 |
161 | it('should publish a configUpdated event', () => {
162 | var event = assertEvent('configUpdated')
163 | assert.deepStrictEqual(event.data.mobbers, [])
164 | assert.strictEqual(event.data.secondsPerTurn, 600)
165 | assert.strictEqual(event.data.secondsUntilFullscreen, 30)
166 | assert.strictEqual(event.data.snapThreshold, 25)
167 | assert.strictEqual(event.data.alertSound, null)
168 | assert.deepStrictEqual(event.data.alertSoundTimes, [])
169 | assert.strictEqual(event.data.timerAlwaysOnTop, true)
170 | assert.strictEqual(event.data.shuffleMobbersOnStartup, false)
171 | assert.strictEqual(event.data.clearClipboardHistoryOnTurnEnd, false)
172 | assert.strictEqual(event.data.numberOfItemsClipboardHistoryStores, 25)
173 | })
174 |
175 | it('should contain the mobbers if there are some', () => {
176 | timerState.addMobber({ name: 'A' })
177 | timerState.addMobber({ name: 'B' })
178 | events = []
179 |
180 | timerState.publishConfig()
181 | var event = assertEvent('configUpdated')
182 | assert.strictEqual(event.data.mobbers[0].name, 'A')
183 | assert.strictEqual(event.data.mobbers[1].name, 'B')
184 |
185 | timerState.removeMobber({ name: 'A' })
186 | timerState.removeMobber({ name: 'B' })
187 | })
188 |
189 | it('should publish a rotated event', () => {
190 | assertEvent('rotated')
191 | })
192 | })
193 |
194 | describe('addMobber', () => {
195 | beforeEach(() => timerState.addMobber({ name: 'A' }))
196 |
197 | it('should publish a configUpdated event', () => {
198 | var event = assertEvent('configUpdated')
199 | assert.strictEqual(event.data.mobbers[0].name, 'A')
200 | assert.strictEqual(event.data.secondsPerTurn, 600)
201 | })
202 |
203 | it('should publish a rotated event', () => {
204 | var event = assertEvent('rotated')
205 | assert.strictEqual(event.data.current.name, 'A')
206 | assert.strictEqual(event.data.next.name, 'A')
207 | })
208 | })
209 |
210 | describe('removeMobber', () => {
211 | beforeEach(() => {
212 | timerState.addMobber({ name: 'A', id: 'a' })
213 | timerState.addMobber({ name: 'B', id: 'b' })
214 | timerState.addMobber({ name: 'C', id: 'c' })
215 | events = []
216 | timerState.removeMobber({ name: 'B', id: 'b' })
217 | })
218 |
219 | it('should publish a configUpdated event', () => {
220 | var event = assertEvent('configUpdated')
221 | assert.strictEqual(event.data.mobbers[0].name, 'A')
222 | assert.strictEqual(event.data.mobbers[1].name, 'C')
223 | assert.strictEqual(event.data.secondsPerTurn, 600)
224 | })
225 |
226 | it('should publish a rotated event', () => {
227 | var event = assertEvent('rotated')
228 | assert.strictEqual(event.data.current.name, 'A')
229 | assert.strictEqual(event.data.next.name, 'C')
230 | })
231 |
232 | it('should NOT publish a turnEnded event if the removed user was NOT current', () => {
233 | var event = events.find(x => x.event === 'turnEnded')
234 | assert.strictEqual(event, undefined)
235 | })
236 |
237 | it('should publish a turnEnded event if the removed user was current', () => {
238 | timerState.removeMobber({ name: 'A' })
239 | assertEvent('turnEnded')
240 | })
241 |
242 | it('should publish a timerChange event if the removed user was current', () => {
243 | timerState.removeMobber({ name: 'A' })
244 | assertEvent('timerChange')
245 | })
246 |
247 | it('should publish a paused event if the removed user was current', () => {
248 | timerState.removeMobber({ name: 'A' })
249 | assertEvent('paused')
250 | })
251 |
252 | it('should update correctly if the removed user was current', () => {
253 | timerState.rotate()
254 | events = []
255 | timerState.removeMobber({ name: 'C', id: 'c' })
256 | var event = assertEvent('rotated')
257 | assert.strictEqual(event.data.current.name, 'A')
258 | assert.strictEqual(event.data.next.name, 'A')
259 | })
260 | })
261 |
262 | describe('updateMobber', () => {
263 | beforeEach(() => {
264 | timerState.addMobber({ id: 'a', name: 'A1' })
265 | events = []
266 | timerState.updateMobber({ id: 'a', name: 'A2' })
267 | })
268 |
269 | it('should publish a configUpdated event', () => {
270 | var event = assertEvent('configUpdated')
271 | assert.strictEqual(event.data.mobbers[0].name, 'A2')
272 | assert.strictEqual(event.data.secondsPerTurn, 600)
273 | })
274 |
275 | it('should update correctly if the update disabled the current mobber', () => {
276 | timerState.addMobber({ id: 'b', name: 'B' })
277 | timerState.addMobber({ id: 'c', name: 'C' })
278 | timerState.rotate()
279 | events = []
280 |
281 | timerState.updateMobber({ id: 'b', name: 'B', disabled: true })
282 |
283 | assertEvent('paused')
284 | assertEvent('turnEnded')
285 | assertEvent('configUpdated')
286 | var rotatedEvent = assertEvent('rotated')
287 | assert.strictEqual(rotatedEvent.data.current.name, 'C')
288 | assert.strictEqual(rotatedEvent.data.next.name, 'A2')
289 | var timerChangeEvent = assertEvent('timerChange')
290 | assert.deepStrictEqual(timerChangeEvent.data, {
291 | secondsRemaining: 600,
292 | secondsPerTurn: 600
293 | })
294 | })
295 | })
296 |
297 | describe('shuffleMobbers', () => {
298 | beforeEach(() => {
299 | const letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
300 | letters.forEach(x => timerState.addMobber({ id: x }))
301 | events = []
302 | timerState.shuffleMobbers()
303 | })
304 |
305 | it('should publish a configUpdated event', () => assertEvent('configUpdated'))
306 |
307 | it('should publish a rotated event', () => assertEvent('rotated'))
308 |
309 | it('should shuffle the mobbers', () => {
310 | const mobbers = timerState.getState().mobbers.map(x => x.id).join('')
311 | assert.notStrictEqual(mobbers, 'abcdefghij')
312 | })
313 | })
314 |
315 | describe('setSecondsPerTurn', () => {
316 | beforeEach(() => timerState.setSecondsPerTurn(300))
317 |
318 | it('should publish a configUpdated event', () => {
319 | var event = assertEvent('configUpdated')
320 | assert.strictEqual(event.data.secondsPerTurn, 300)
321 | })
322 |
323 | it('should publish a timerChange event', () => {
324 | var event = assertEvent('timerChange')
325 | assert.deepStrictEqual(event.data, {
326 | secondsRemaining: 300,
327 | secondsPerTurn: 300
328 | })
329 | })
330 | })
331 |
332 | describe('setSecondsUntilFullscreen', () => {
333 | beforeEach(() => timerState.setSecondsUntilFullscreen(5))
334 |
335 | it('should publish a configUpdated event', () => {
336 | var event = assertEvent('configUpdated')
337 | assert.strictEqual(event.data.secondsUntilFullscreen, 5)
338 | })
339 | })
340 |
341 | describe('when setting snap threshold', () => {
342 | beforeEach(() => timerState.setSnapThreshold(100))
343 |
344 | it('should publish configUpdated event', () => {
345 | var event = assertEvent('configUpdated')
346 | assert.strictEqual(event.data.snapThreshold, 100)
347 | })
348 | })
349 |
350 | describe('when setting the alert sound file', () => {
351 | beforeEach(() => timerState.setAlertSound('new-sound.mp3'))
352 |
353 | it('should publish a configUpdated event', () => {
354 | var event = assertEvent('configUpdated')
355 | assert.strictEqual(event.data.alertSound, 'new-sound.mp3')
356 | })
357 | })
358 |
359 | describe('when setting the alert sound times', () => {
360 | beforeEach(() => timerState.setAlertSoundTimes([1, 2, 3]))
361 |
362 | it('should publish a configUpdated event', () => {
363 | var event = assertEvent('configUpdated')
364 | assert.deepStrictEqual(event.data.alertSoundTimes, [1, 2, 3])
365 | })
366 | })
367 |
368 | describe('when setting the timer always on top', () => {
369 | beforeEach(() => timerState.setTimerAlwaysOnTop(false))
370 |
371 | it('should publish a configUpdated event', () => {
372 | var event = assertEvent('configUpdated')
373 | assert.deepStrictEqual(event.data.timerAlwaysOnTop, false)
374 | })
375 | })
376 |
377 | describe('when setting shuffle mobbers on startup', () => {
378 | beforeEach(() => timerState.setShuffleMobbersOnStartup(true))
379 |
380 | it('should publish a configUpdated event', () => {
381 | var event = assertEvent('configUpdated')
382 | assert.deepStrictEqual(event.data.shuffleMobbersOnStartup, true)
383 | })
384 | })
385 |
386 | describe('when setting clear clipboard history between turns', () => {
387 | beforeEach(() => timerState.setClearClipboardHistoryOnTurnEnd(true))
388 |
389 | it('should publish a configUpdated event', () => {
390 | var event = assertEvent('configUpdated')
391 | assert.deepStrictEqual(event.data.clearClipboardHistoryOnTurnEnd, true)
392 | })
393 | })
394 |
395 | describe('when setting number of items clipboard history stores', () => {
396 | beforeEach(() => timerState.setNumberOfItemsClipboardHistoryStores(10))
397 |
398 | it('should publish a configUpdated event', () => {
399 | var event = assertEvent('configUpdated')
400 | assert.deepStrictEqual(event.data.numberOfItemsClipboardHistoryStores, 10)
401 | })
402 | })
403 |
404 | describe('getState', () => {
405 | describe('when getting non-default state', () => {
406 | beforeEach(() => {
407 | timerState.addMobber(expectedJack)
408 | timerState.addMobber(expectedJill)
409 | timerState.setSecondsPerTurn(expectedSecondsPerTurn)
410 | timerState.setSecondsUntilFullscreen(expectedSecondsUntilFullscreen)
411 | timerState.setSnapThreshold(expectedSnapThreshold)
412 | timerState.setAlertSound(expectedAlertSound)
413 | timerState.setAlertSoundTimes(expectedAlertSoundTimes)
414 | timerState.setTimerAlwaysOnTop(expectedTimerAlwaysOnTop)
415 | timerState.setShuffleMobbersOnStartup(expectedShuffleMobbersOnStartup)
416 | timerState.setClearClipboardHistoryOnTurnEnd(expectedClearClipboardHistoryOnTurnEnd)
417 | timerState.setNumberOfItemsClipboardHistoryStores(expectedNumberOfItemsClipboardHistoryStores)
418 |
419 | result = timerState.getState()
420 | })
421 |
422 | it('should get correct mobbers', () => {
423 | var actualJack = result.mobbers.find(x => x.name === expectedJack.name)
424 | var actualJill = result.mobbers.find(x => x.name === expectedJill.name)
425 |
426 | assert.deepStrictEqual(expectedJack, actualJack)
427 | assert.deepStrictEqual(expectedJill, actualJill)
428 | })
429 |
430 | it('should get correct seconds per turn', () => {
431 | assert.strictEqual(result.secondsPerTurn, expectedSecondsPerTurn)
432 | })
433 |
434 | it('should get the correct seconds until fullscreen', () => {
435 | assert.strictEqual(result.secondsUntilFullscreen, expectedSecondsUntilFullscreen)
436 | })
437 |
438 | it('should get the correct seconds until fullscreen', () => {
439 | assert.strictEqual(result.snapThreshold, expectedSnapThreshold)
440 | })
441 |
442 | it('should get the correct alert sound', () => {
443 | assert.strictEqual(result.alertSound, expectedAlertSound)
444 | })
445 |
446 | it('should get the correct alert sound times', () => {
447 | assert.strictEqual(result.alertSoundTimes, expectedAlertSoundTimes)
448 | })
449 |
450 | it('should get the correct timer always on top', () => {
451 | assert.strictEqual(result.timerAlwaysOnTop, expectedTimerAlwaysOnTop)
452 | })
453 |
454 | it('should get the correct shuffle mobbers on startup', () => {
455 | assert.strictEqual(result.shuffleMobbersOnStartup, expectedShuffleMobbersOnStartup)
456 | })
457 |
458 | it('should get the correct clear clipboard history between turns', () => {
459 | assert.strictEqual(result.clearClipboardHistoryOnTurnEnd, expectedClearClipboardHistoryOnTurnEnd)
460 | })
461 |
462 | it('should get the correct number of items clipboard history stores', () => {
463 | assert.strictEqual(result.numberOfItemsClipboardHistoryStores, expectedNumberOfItemsClipboardHistoryStores)
464 | })
465 |
466 | let result = {}
467 | let expectedJack = { name: 'jack' }
468 | let expectedJill = { name: 'jill' }
469 | let expectedSecondsPerTurn = 599
470 | let expectedSecondsUntilFullscreen = 3
471 | let expectedSnapThreshold = 42
472 | let expectedAlertSound = 'alert.mp3'
473 | let expectedAlertSoundTimes = [0, 15]
474 | let expectedTimerAlwaysOnTop = false
475 | let expectedShuffleMobbersOnStartup = true
476 | let expectedClearClipboardHistoryOnTurnEnd = true
477 | let expectedNumberOfItemsClipboardHistoryStores = 13
478 | })
479 |
480 | describe('when getting default state', () => {
481 | beforeEach(() => (result = timerState.getState()))
482 |
483 | it('should get no mobbers', () => assert(result.mobbers.length === 0))
484 | it('should have a default secondsPerTurn greater than zero', () => assert(result.secondsPerTurn > 0))
485 | it('should have a default snapThreshold greater than zero', () => assert(result.snapThreshold > 0))
486 | it('should have a null alert sound', () => assert(result.alertSound === null))
487 | it('should have an empty array of alert sound times', () => assert.deepStrictEqual(result.alertSoundTimes, []))
488 | it('should have a default timerAlwaysOnTop', () => assert.deepStrictEqual(result.timerAlwaysOnTop, true))
489 | it('should have a default shuffleMobbersOnStartup', () => assert.strictEqual(result.shuffleMobbersOnStartup, false))
490 | it('should have a default clearClipboardHistoryOnTurnEnd', () => assert.strictEqual(result.clearClipboardHistoryOnTurnEnd, false))
491 | it('should have a default numberOfItemsClipboardHistoryStores', () => assert.strictEqual(result.numberOfItemsClipboardHistoryStores, 25))
492 |
493 | let result = {}
494 | })
495 |
496 | describe('when there is one mobber', () => {
497 | before(() => {
498 | timerState.addMobber(expectedJack)
499 |
500 | result = timerState.getState()
501 | })
502 |
503 | it('should get correct mobber', () => {
504 | var actualJack = result.mobbers.find(x => x.name === expectedJack.name)
505 |
506 | assert.deepStrictEqual(expectedJack, actualJack)
507 | })
508 |
509 | let result = {}
510 | let expectedJack = { name: 'jack' }
511 | })
512 | })
513 |
514 | describe('loadState', () => {
515 | describe('when loading state data', () => {
516 | before(() => {
517 | state = {
518 | mobbers: [{ name: 'jack' }, { name: 'jill' }],
519 | secondsPerTurn: 400,
520 | secondsUntilFullscreen: 0,
521 | snapThreshold: 22,
522 | alertSound: 'bell.mp3',
523 | alertSoundTimes: [2, 3, 5, 8],
524 | timerAlwaysOnTop: false,
525 | shuffleMobbersOnStartup: true,
526 | clearClipboardHistoryOnTurnEnd: true,
527 | numberOfItemsClipboardHistoryStores: 20
528 | }
529 |
530 | timerState.loadState(state)
531 |
532 | result = timerState.getState()
533 | })
534 |
535 | it('should load mobbers', () => assert.deepStrictEqual(result.mobbers, state.mobbers))
536 | it('should load secondsPerTurn', () => assert.strictEqual(result.secondsPerTurn, state.secondsPerTurn))
537 | it('should load secondsUntilFullscreen', () => assert.strictEqual(result.secondsUntilFullscreen, state.secondsUntilFullscreen))
538 | it('should load snapThreshold', () => assert.strictEqual(result.snapThreshold, state.snapThreshold))
539 | it('should load alertSound', () => assert.strictEqual(result.alertSound, state.alertSound))
540 | it('should load alertSoundTimes', () => assert.deepStrictEqual(result.alertSoundTimes, [2, 3, 5, 8]))
541 | it('should load timerAlwaysOnTop', () => assert.strictEqual(result.timerAlwaysOnTop, state.timerAlwaysOnTop))
542 | it('should load shuffleMobbersOnStartup', () => assert.strictEqual(result.shuffleMobbersOnStartup, state.shuffleMobbersOnStartup))
543 | it('should load clearClipboardHistoryOnTurnEnd', () => assert.strictEqual(result.clearClipboardHistoryOnTurnEnd, state.clearClipboardHistoryOnTurnEnd))
544 | it('should load numberOfItemsClipboardHistoryStores', () => assert.strictEqual(result.numberOfItemsClipboardHistoryStores, state.numberOfItemsClipboardHistoryStores))
545 |
546 | let result = {}
547 | let state = {}
548 | })
549 |
550 | describe('when loading an empty state', () => {
551 | before(() => {
552 | timerState.loadState({})
553 |
554 | result = timerState.getState()
555 | })
556 |
557 | it('should NOT load any mobbers', () => assert.strictEqual(result.mobbers.length, 0))
558 | it('should have a default secondsPerTurn greater than zero', () => assert(result.secondsPerTurn > 0))
559 | it('should have a default secondsUntilFullscreen greater than zero', () => assert(result.secondsUntilFullscreen > 0))
560 | it('should have a default snapThreshold greater than zero', () => assert(result.snapThreshold > 0))
561 | it('should have a null alertSound', () => assert.strictEqual(result.alertSound, null))
562 | it('should have an empty array of alertSoundTimes', () => assert.deepStrictEqual(result.alertSoundTimes, []))
563 | it('should have a default timerAlwaysOnTop', () => assert.strictEqual(result.timerAlwaysOnTop, true))
564 | it('should have a default shuffleMobbersOnStartup', () => assert.strictEqual(result.shuffleMobbersOnStartup, false))
565 | it('should have a default clearClipboardHistoryOnTurnEnd', () => assert.strictEqual(result.clearClipboardHistoryOnTurnEnd, false))
566 | it('should have a default numberOfItemsClipboardHistoryStores greater than zero', () => assert(result.numberOfItemsClipboardHistoryStores > 0))
567 |
568 | let result = {}
569 | })
570 |
571 | describe('when loading state with one mobber', () => {
572 | before(() => {
573 | state = {
574 | mobbers: [{ name: 'jack' }]
575 | }
576 |
577 | timerState.loadState(state)
578 |
579 | result = timerState.getState()
580 | })
581 |
582 | it('should load one mobber', () => assert.deepStrictEqual(state.mobbers, result.mobbers))
583 |
584 | let result = {}
585 | let state = {}
586 | })
587 | })
588 | })
589 |
--------------------------------------------------------------------------------