Extract DrivesModel from DriveScannerService (#466)

Currently, `DriveScannerService` is in charge of both scanning the
available drives and maintaining the state of the currently detected
drives.

To honour the single responsibility principle, we split this service
into a `DrivesModel`, which is incharge of maintaining the state without
caring how they are detected, and `DriveScannerService`, which only
scans the available drives and modified `DrivesModel` accordingly.

Signed-off-by: Juan Cruz Viotti <jviottidc@gmail.com>
This commit is contained in:
Juan Cruz Viotti 2016-06-09 12:42:09 -04:00
parent fc6ccf70dd
commit b950136deb
9 changed files with 260 additions and 148 deletions

View File

@ -38,6 +38,7 @@ const app = angular.module('Etcher', [
require('./models/selection-state'), require('./models/selection-state'),
require('./models/settings'), require('./models/settings'),
require('./models/supported-formats'), require('./models/supported-formats'),
require('./models/drives'),
// Components // Components
require('./components/progress-button/progress-button'), require('./components/progress-button/progress-button'),
@ -87,6 +88,7 @@ app.controller('AppController', function(
SelectionStateModel, SelectionStateModel,
SettingsModel, SettingsModel,
SupportedFormatsModel, SupportedFormatsModel,
DrivesModel,
ImageWriterService, ImageWriterService,
AnalyticsService, AnalyticsService,
DriveSelectorService, DriveSelectorService,
@ -98,8 +100,8 @@ app.controller('AppController', function(
let self = this; let self = this;
this.formats = SupportedFormatsModel; this.formats = SupportedFormatsModel;
this.selection = SelectionStateModel; this.selection = SelectionStateModel;
this.drives = DrivesModel;
this.writer = ImageWriterService; this.writer = ImageWriterService;
this.scanner = DriveScannerService;
this.settings = SettingsModel.data; this.settings = SettingsModel.data;
this.success = true; this.success = true;
@ -142,7 +144,7 @@ app.controller('AppController', function(
OSWindowProgressService.set(state.progress); OSWindowProgressService.set(state.progress);
}); });
this.scanner.start(2000).on('error', OSDialogService.showError).on('scan', function(drives) { DriveScannerService.start(2000).on('error', OSDialogService.showError).on('scan', function(drives) {
// Cover the case where you select a drive, but then eject it. // Cover the case where you select a drive, but then eject it.
if (self.selection.hasDrive() && !_.find(drives, self.selection.isCurrentDrive)) { if (self.selection.hasDrive() && !_.find(drives, self.selection.isCurrentDrive)) {
@ -267,7 +269,7 @@ app.controller('AppController', function(
// Stop scanning drives when flashing // Stop scanning drives when flashing
// otherwise Windows throws EPERM // otherwise Windows throws EPERM
self.scanner.stop(); DriveScannerService.stop();
AnalyticsService.logEvent('Flash', { AnalyticsService.logEvent('Flash', {
image: image, image: image,

View File

@ -18,7 +18,7 @@
const _ = require('lodash'); const _ = require('lodash');
module.exports = function($uibModalInstance, DriveScannerService, SelectionStateModel) { module.exports = function($uibModalInstance, DrivesModel, SelectionStateModel) {
/** /**
* @summary The drive selector state * @summary The drive selector state
@ -28,7 +28,7 @@ module.exports = function($uibModalInstance, DriveScannerService, SelectionState
this.state = SelectionStateModel; this.state = SelectionStateModel;
/** /**
* @summary The drive scanner service * @summary The drives model
* @property * @property
* @type Object * @type Object
* *
@ -36,9 +36,9 @@ module.exports = function($uibModalInstance, DriveScannerService, SelectionState
* We expose the whole service instead of the `.drives` * We expose the whole service instead of the `.drives`
* property, which is the one we're interested in since * property, which is the one we're interested in since
* this allows the property to be automatically updated * this allows the property to be automatically updated
* when `DriveScannerService` detects a change in the drives. * when `DrivesModel` detects a change in the drives.
*/ */
this.scanner = DriveScannerService; this.drives = DrivesModel;
/** /**
* @summary Close the modal and resolve the selected drive * @summary Close the modal and resolve the selected drive
@ -54,7 +54,7 @@ module.exports = function($uibModalInstance, DriveScannerService, SelectionState
// Sanity check to cover the case where a drive is selected, // Sanity check to cover the case where a drive is selected,
// the drive is then unplugged from the computer and the modal // the drive is then unplugged from the computer and the modal
// is resolved with a non-existent drive. // is resolved with a non-existent drive.
if (!selectedDrive || !_.includes(this.scanner.drives, selectedDrive)) { if (!selectedDrive || !_.includes(this.drives.getDrives(), selectedDrive)) {
return $uibModalInstance.dismiss(); return $uibModalInstance.dismiss();
} }

View File

@ -24,7 +24,7 @@ const angular = require('angular');
const MODULE_NAME = 'Etcher.Components.DriveSelector'; const MODULE_NAME = 'Etcher.Components.DriveSelector';
const DriveSelector = angular.module(MODULE_NAME, [ const DriveSelector = angular.module(MODULE_NAME, [
require('angular-ui-bootstrap'), require('angular-ui-bootstrap'),
require('../../modules/drive-scanner'), require('../../models/drives'),
require('../../models/selection-state'), require('../../models/selection-state'),
require('../../utils/byte-size/byte-size') require('../../utils/byte-size/byte-size')
]); ]);

View File

@ -5,7 +5,7 @@
<div class="modal-body"> <div class="modal-body">
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item" ng-repeat="drive in modal.scanner.drives" <li class="list-group-item" ng-repeat="drive in modal.drives.getDrives()"
ng-disabled="!modal.state.isDriveLargeEnough(drive)" ng-disabled="!modal.state.isDriveLargeEnough(drive)"
ng-click="modal.state.isDriveLargeEnough(drive) && modal.state.toggleSetDrive(drive)"> ng-click="modal.state.isDriveLargeEnough(drive) && modal.state.toggleSetDrive(drive)">
<div> <div>

101
lib/gui/models/drives.js Normal file
View File

@ -0,0 +1,101 @@
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
/**
* @module Etcher.Models.Drives
*/
const angular = require('angular');
const _ = require('lodash');
const MODULE_NAME = 'Etcher.Models.Drives';
const Drives = angular.module(MODULE_NAME, []);
Drives.service('DrivesModel', function() {
/**
* @summary List of available drives
* @type {Object[]}
* @private
*/
let availableDrives = [];
/**
* @summary Check if there are available drives
* @function
* @public
*
* @returns {Boolean} whether there are available drives
*
* @example
* if (DrivesModel.hasAvailableDrives()) {
* console.log('There are available drives!');
* }
*/
this.hasAvailableDrives = function() {
return !_.isEmpty(availableDrives);
};
/**
* @summary Set a list of drives
* @function
* @private
*
* @param {Object[]} drives - drives
*
* @throws Will throw if no drives
* @throws Will throw if drives is not an array of objects
*
* @example
* DrivesModel.setDrives([ ... ]);
*/
this.setDrives = function(drives) {
if (!drives) {
throw new Error('Missing drives');
}
if (!_.isArray(drives) || !_.every(drives, _.isPlainObject)) {
throw new Error(`Invalid drives: ${drives}`);
}
// Only update if something has changed
// to avoid unnecessary DOM manipulations
// angular.equals ignores $$hashKey by default
if (!angular.equals(availableDrives, drives)) {
availableDrives = drives;
}
};
/**
* @summary Get detected drives
* @function
* @private
*
* @returns {Object[]} drives
*
* @example
* const drives = DrivesModel.getDrives();
*/
this.getDrives = function() {
return availableDrives;
};
});
module.exports = MODULE_NAME;

View File

@ -27,58 +27,14 @@ const drivelist = require('drivelist');
const MODULE_NAME = 'Etcher.drive-scanner'; const MODULE_NAME = 'Etcher.drive-scanner';
const driveScanner = angular.module(MODULE_NAME, [ const driveScanner = angular.module(MODULE_NAME, [
require('angular-q-promisify') require('angular-q-promisify'),
require('../models/drives')
]); ]);
driveScanner.service('DriveScannerService', function($q, $interval, $timeout) { driveScanner.service('DriveScannerService', function($q, $interval, $timeout, DrivesModel) {
let self = this; let self = this;
let interval = null; let interval = null;
/**
* @summary List of available drives
* @type {Object[]}
* @public
*/
this.drives = [];
/**
* @summary Check if there are available drives
* @function
* @public
*
* @returns {Boolean} whether there are available drives
*
* @example
* if (DriveScannerService.hasAvailableDrives()) {
* console.log('There are available drives!');
* }
*/
this.hasAvailableDrives = function() {
return !_.isEmpty(self.drives);
};
/**
* @summary Set the list of drives
* @function
* @private
*
* @param {Object[]} drives - drives
*
* @example
* DriveScannerService.scan().then(function(drives) {
* DriveScannerService.setDrives(drives);
* });
*/
this.setDrives = function(drives) {
// Only update if something has changed
// to avoid unnecessary DOM manipulations
// angular.equals ignores $$hashKey by default
if (!angular.equals(self.drives, drives)) {
self.drives = drives;
}
};
/** /**
* @summary Get available drives * @summary Get available drives
* @function * @function
@ -101,7 +57,7 @@ driveScanner.service('DriveScannerService', function($q, $interval, $timeout) {
}; };
/** /**
* @summary Scan drives and populate `.drives` * @summary Scan drives and populate DrivesModel
* @function * @function
* @public * @public
* *
@ -126,7 +82,7 @@ driveScanner.service('DriveScannerService', function($q, $interval, $timeout) {
const fn = function() { const fn = function() {
return self.scan().then(function(drives) { return self.scan().then(function(drives) {
emitter.emit('scan', drives); emitter.emit('scan', drives);
self.setDrives(drives); DrivesModel.setDrives(drives);
}).catch(function(error) { }).catch(function(error) {
emitter.emit('error', error); emitter.emit('error', error);
}); });

View File

@ -42,13 +42,13 @@
<div class="space-vertical-large"> <div class="space-vertical-large">
<div ng-hide="app.selection.hasDrive()"> <div ng-hide="app.selection.hasDrive()">
<div ng-show="app.scanner.hasAvailableDrives() || !app.selection.hasImage()"> <div ng-show="app.drives.hasAvailableDrives() || !app.selection.hasImage()">
<button class="btn btn-primary btn-brick" <button class="btn btn-primary btn-brick"
ng-disabled="!app.selection.hasImage()" ng-disabled="!app.selection.hasImage()"
ng-click="app.openDriveSelector()">Select drive</button> ng-click="app.openDriveSelector()">Select drive</button>
</div> </div>
<div ng-hide="app.scanner.hasAvailableDrives() || !app.selection.hasImage()"> <div ng-hide="app.drives.hasAvailableDrives() || !app.selection.hasImage()">
<button class="btn btn-danger btn-brick">Connect a drive</button> <button class="btn btn-danger btn-brick">Connect a drive</button>
</div> </div>

View File

@ -0,0 +1,132 @@
'use strict';
const m = require('mochainon');
const angular = require('angular');
require('angular-mocks');
describe('Browser: DrivesModel', function() {
beforeEach(angular.mock.module(
require('../../../lib/gui/models/drives')
));
describe('DrivesModel', function() {
let DrivesModel;
beforeEach(angular.mock.inject(function(_DrivesModel_) {
DrivesModel = _DrivesModel_;
}));
it('should have no drives by default', function() {
m.chai.expect(DrivesModel.getDrives()).to.deep.equal([]);
});
describe('.setDrives()', function() {
it('should throw if no drives', function() {
m.chai.expect(function() {
DrivesModel.setDrives();
}).to.throw('Missing drives');
});
it('should throw if drives is not an array', function() {
m.chai.expect(function() {
DrivesModel.setDrives(123);
}).to.throw('Invalid drives: 123');
});
it('should throw if drives is not an array of objects', function() {
m.chai.expect(function() {
DrivesModel.setDrives([
123,
123,
123
]);
}).to.throw('Invalid drives: 123,123,123');
});
});
describe('given no drives', function() {
describe('.hasAvailableDrives()', function() {
it('should return false', function() {
m.chai.expect(DrivesModel.hasAvailableDrives()).to.be.false;
});
});
describe('.setDrives()', function() {
it('should be able to set drives', function() {
const drives = [
{
device: '/dev/sdb',
description: 'Foo',
size: '14G',
mountpoint: '/mnt/foo',
system: false
}
];
DrivesModel.setDrives(drives);
m.chai.expect(DrivesModel.getDrives()).to.deep.equal(drives);
});
});
});
describe('given drives', function() {
beforeEach(function() {
this.drives = [
{
device: '/dev/sdb',
description: 'Foo',
size: '14G',
mountpoint: '/mnt/foo',
system: false
},
{
device: '/dev/sdc',
description: 'Bar',
size: '14G',
mountpoint: '/mnt/bar',
system: false
}
];
DrivesModel.setDrives(this.drives);
});
describe('.hasAvailableDrives()', function() {
it('should return true', function() {
const hasDrives = DrivesModel.hasAvailableDrives();
m.chai.expect(hasDrives).to.be.true;
});
});
describe('.setDrives()', function() {
it('should keep the same drives if equal', function() {
DrivesModel.setDrives(this.drives);
m.chai.expect(DrivesModel.getDrives()).to.deep.equal(this.drives);
});
it('should consider drives with different $$hashKey the same', function() {
this.drives[0].$$haskey = 1234;
DrivesModel.setDrives(this.drives);
m.chai.expect(DrivesModel.getDrives()).to.deep.equal(this.drives);
});
});
});
});
});

View File

@ -11,26 +11,28 @@ describe('Browser: DriveScanner', function() {
require('../../../lib/gui/modules/drive-scanner') require('../../../lib/gui/modules/drive-scanner')
)); ));
beforeEach(angular.mock.module(
require('../../../lib/gui/models/drives')
));
describe('DriveScannerService', function() { describe('DriveScannerService', function() {
let $interval; let $interval;
let $rootScope; let $rootScope;
let $timeout; let $timeout;
let $q; let $q;
let DrivesModel;
let DriveScannerService; let DriveScannerService;
beforeEach(angular.mock.inject(function(_$interval_, _$rootScope_, _$timeout_, _$q_, _DriveScannerService_) { beforeEach(angular.mock.inject(function(_$interval_, _$rootScope_, _$timeout_, _$q_, _DriveScannerService_, _DrivesModel_) {
$interval = _$interval_; $interval = _$interval_;
$rootScope = _$rootScope_; $rootScope = _$rootScope_;
$timeout = _$timeout_; $timeout = _$timeout_;
$q = _$q_; $q = _$q_;
DriveScannerService = _DriveScannerService_; DriveScannerService = _DriveScannerService_;
DrivesModel = _DrivesModel_;
})); }));
it('should have no drives by default', function() {
m.chai.expect(DriveScannerService.drives).to.deep.equal([]);
});
describe('.scan()', function() { describe('.scan()', function() {
describe('given no available drives', function() { describe('given no available drives', function() {
@ -173,87 +175,6 @@ describe('Browser: DriveScanner', function() {
}); });
describe('given no drives', function() {
describe('.hasAvailableDrives()', function() {
it('should return false', function() {
const hasDrives = DriveScannerService.hasAvailableDrives();
m.chai.expect(hasDrives).to.be.false;
});
});
describe('.setDrives()', function() {
it('should be able to set drives', function() {
const drives = [
{
device: '/dev/sdb',
description: 'Foo',
size: '14G',
mountpoint: '/mnt/foo',
system: false
}
];
DriveScannerService.setDrives(drives);
m.chai.expect(DriveScannerService.drives).to.deep.equal(drives);
});
});
});
describe('given drives', function() {
beforeEach(function() {
this.drives = [
{
device: '/dev/sdb',
description: 'Foo',
size: '14G',
mountpoint: '/mnt/foo',
system: false
},
{
device: '/dev/sdc',
description: 'Bar',
size: '14G',
mountpoint: '/mnt/bar',
system: false
}
];
DriveScannerService.drives = this.drives;
});
describe('.hasAvailableDrives()', function() {
it('should return true', function() {
const hasDrives = DriveScannerService.hasAvailableDrives();
m.chai.expect(hasDrives).to.be.true;
});
});
describe('.setDrives()', function() {
it('should keep the same drives if equal', function() {
DriveScannerService.setDrives(this.drives);
m.chai.expect(DriveScannerService.drives).to.deep.equal(this.drives);
});
it('should consider drives with different $$hashKey the same', function() {
this.drives[0].$$haskey = 1234;
DriveScannerService.setDrives(this.drives);
m.chai.expect(DriveScannerService.drives).to.deep.equal(this.drives);
});
});
});
describe('given available drives', function() { describe('given available drives', function() {
beforeEach(function() { beforeEach(function() {
@ -286,7 +207,7 @@ describe('Browser: DriveScanner', function() {
DriveScannerService.start(200); DriveScannerService.start(200);
$timeout.flush(); $timeout.flush();
$interval.flush(400); $interval.flush(400);
m.chai.expect(DriveScannerService.drives).to.deep.equal(this.drives); m.chai.expect(DrivesModel.getDrives()).to.deep.equal(this.drives);
DriveScannerService.stop(); DriveScannerService.stop();
}); });