diff --git a/lib/gui/components/drive-selector/controllers/drive-selector.js b/lib/gui/components/drive-selector/controllers/drive-selector.js
index 1005120e..99013a2f 100644
--- a/lib/gui/components/drive-selector/controllers/drive-selector.js
+++ b/lib/gui/components/drive-selector/controllers/drive-selector.js
@@ -16,6 +16,7 @@
'use strict';
+const angular = require('angular');
const _ = require('lodash');
const messages = require('../../../../shared/messages');
const constraints = require('../../../../shared/drive-constraints');
@@ -170,4 +171,91 @@ module.exports = function(
});
};
+ /**
+ * @summary Memoize ImmutableJS list reference
+ * @function
+ * @private
+ *
+ * @description
+ * This workaround is needed to avoid AngularJS from getting
+ * caught in an infinite digest loop when using `ngRepeat`
+ * over a function that returns a mutable version of an
+ * ImmutableJS object.
+ *
+ * The problem is that every time you call `myImmutableObject.toJS()`
+ * you will get a new object, whose reference is different from
+ * the one you previously got, even if the data is exactly the same.
+ *
+ * @param {Function} func - function that returns an ImmutableJS list
+ * @returns {Function} memoized function
+ *
+ * @example
+ * const getList = () => {
+ * return Store.getState().toJS().myList;
+ * };
+ *
+ * const memoizedFunction = memoizeImmutableListReference(getList);
+ */
+ this.memoizeImmutableListReference = (func) => {
+ let previousTuples = [];
+
+ return (...restArgs) => {
+ let areArgsInTuple = false;
+ let state = Reflect.apply(func, this, restArgs);
+
+ previousTuples = _.map(previousTuples, ([ oldArgs, oldState ]) => {
+ if (angular.equals(oldArgs, restArgs)) {
+ areArgsInTuple = true;
+
+ if (angular.equals(state, oldState)) {
+
+ // Use the previously memoized state for this argument
+ state = oldState;
+ }
+
+ // Update the tuple state
+ return [ oldArgs, state ];
+ }
+
+ // Return the tuple unchanged
+ return [ oldArgs, oldState ];
+ });
+
+ // Add the state associated with these args to be memoized
+ if (!areArgsInTuple) {
+ previousTuples.push([ restArgs, state ]);
+
+ }
+
+ return state;
+ };
+ };
+
+ this.getDrives = this.memoizeImmutableListReference(() => {
+ return this.drives.getDrives();
+ });
+
+ /**
+ * @summary Get a drive's compatibility status object(s)
+ * @function
+ * @public
+ *
+ * @description
+ * Given a drive, return its compatibility status with the selected image,
+ * containing the status type (ERROR, WARNING), and accompanying
+ * status message.
+ *
+ * @returns {Object[]} list of objects containing statuses
+ *
+ * @example
+ * const statuses = DriveSelectorController.getDriveStatuses(drive);
+ *
+ * for ({ type, message } of statuses) {
+ * // do something
+ * }
+ */
+ this.getDriveStatuses = this.memoizeImmutableListReference((drive) => {
+ return this.constraints.getDriveImageCompatibilityStatuses(drive, this.state.getImage());
+ });
+
};
diff --git a/lib/gui/components/drive-selector/templates/drive-selector-modal.tpl.html b/lib/gui/components/drive-selector/templates/drive-selector-modal.tpl.html
index 30530d66..3525ea63 100644
--- a/lib/gui/components/drive-selector/templates/drive-selector-modal.tpl.html
+++ b/lib/gui/components/drive-selector/templates/drive-selector-modal.tpl.html
@@ -5,7 +5,7 @@
diff --git a/lib/gui/models/drives.js b/lib/gui/models/drives.js
index 0bd9ecba..617d5c69 100644
--- a/lib/gui/models/drives.js
+++ b/lib/gui/models/drives.js
@@ -64,45 +64,6 @@ Drives.service('DrivesModel', function() {
});
};
- /**
- * @summary Memoize ImmutableJS list reference
- * @function
- * @private
- *
- * @description
- * This workaround is needed to avoid AngularJS from getting
- * caught in an infinite digest loop when using `ngRepeat`
- * over a function that returns a mutable version of an
- * ImmutableJS object.
- *
- * The problem is that every time you call `myImmutableObject.toJS()`
- * you will get a new object, whose reference is different from
- * the one you previously got, even if the data is exactly the same.
- *
- * @param {Function} func - function that returns an ImmutableJS list
- * @returns {Function} memoized function
- *
- * @example
- * const getList = () => {
- * return Store.getState().toJS().myList;
- * };
- *
- * const memoizedFunction = memoizeImmutableListReference(getList);
- */
- const memoizeImmutableListReference = (func) => {
- let previous = [];
-
- return (...args) => {
- const list = Reflect.apply(func, this, args);
-
- if (!_.isEqual(list, previous)) {
- previous = list;
- }
-
- return previous;
- };
- };
-
/**
* @summary Get detected drives
* @function
@@ -113,9 +74,9 @@ Drives.service('DrivesModel', function() {
* @example
* const drives = DrivesModel.getDrives();
*/
- this.getDrives = memoizeImmutableListReference(() => {
+ this.getDrives = () => {
return Store.getState().toJS().availableDrives;
- });
+ };
});
diff --git a/lib/shared/drive-constraints.js b/lib/shared/drive-constraints.js
index 585ac506..2efa9dcf 100644
--- a/lib/shared/drive-constraints.js
+++ b/lib/shared/drive-constraints.js
@@ -232,3 +232,152 @@ exports.isDriveValid = (drive, image) => {
exports.isDriveSizeRecommended = (drive, image) => {
return _.get(drive, [ 'size' ], UNKNOWN_SIZE) >= _.get(image, [ 'recommendedDriveSize' ], UNKNOWN_SIZE);
};
+
+/**
+ * @summary Drive/image compatibility status messages.
+ * @public
+ * @type {Object}
+ *
+ * @description
+ * Status messages intended to be shown to the user.
+ */
+exports.COMPATIBILITY_STATUS_MESSAGES = {
+
+ /**
+ * @property {String} SIZE_NOT_RECOMMENDED
+ * @memberof COMPATIBILITY_STATUS_MESSAGES
+ *
+ * @description
+ * The image and drive compatibility is not recommended; happens when the
+ * actual drive size is smaller than the image's recommended drive size.
+ */
+ SIZE_NOT_RECOMMENDED: 'Not Recommended',
+
+ /**
+ * @property {String} TOO_SMALL
+ * @memberof COMPATIBILITY_STATUS_MESSAGES
+ *
+ * @description
+ * The drive is too small for the image.
+ */
+ TOO_SMALL: 'Too Small For Image',
+
+ /**
+ * @property {String} LOCKED
+ * @memberof COMPATIBILITY_STATUS_MESSAGES
+ *
+ * @description
+ * The drive is locked (e.g. the lock-tab on SD cards) and cannot be written to.
+ */
+ LOCKED: 'Locked',
+
+ /**
+ * @property {String} SYSTEM
+ * @memberof COMPATIBILITY_STATUS_MESSAGES
+ *
+ * @description
+ * The drive is a system drive and should not be written to.
+ */
+ SYSTEM: 'System Drive',
+
+ /**
+ * @property {String} CONTAINS_IMAGE
+ * @memberof COMPATIBILITY_STATUS_MESSAGES
+ *
+ * @description
+ * The drive contains the image and therefore cannot be written to.
+ */
+ CONTAINS_IMAGE: 'Drive Contains Image'
+};
+
+/**
+ * @summary Drive/image compatibility status types.
+ * @public
+ * @type {Object}
+ *
+ * @description
+ * Status types classifying what kind of message it is, i.e. error, warning.
+ */
+exports.COMPATIBILITY_STATUS_TYPES = {
+ WARNING: 1,
+ ERROR: 2
+};
+
+/**
+ * @summary Get drive/image compatibility in an object
+ * @function
+ * @public
+ *
+ * @description
+ * Given an image and a drive, return their compatibility status object
+ * containing the status type (ERROR, WARNING), and accompanying
+ * status message.
+ *
+ * @param {Object} drive - drive
+ * @param {Object} image - image
+ * @returns {Object[]} list of compatibility status objects
+ *
+ * @example
+ * const drive = {
+ * device: '/dev/disk2',
+ * name: 'My Drive',
+ * size: 4000000000
+ * };
+ *
+ * const image = {
+ * path: '/path/to/rpi.img',
+ * size: 2000000000
+ * recommendedDriveSize: 4000000000
+ * });
+ *
+ * const statuses = constraints.getDriveImageCompatibilityStatuses(drive, image);
+ *
+ * for ({ type, message } of statuses) {
+ * if (type === constraints.COMPATIBILITY_STATUS_TYPES.WARNING) {
+ * // do something
+ * } else if (type === constraints.COMPATIBILITY_STATUS_TYPES.ERROR) {
+ * // do something else
+ * }
+ * }
+ */
+exports.getDriveImageCompatibilityStatuses = (drive, image) => {
+ const statusList = [];
+
+ // Mind the order of the if-statements if you modify.
+ if (exports.isSourceDrive(drive, image)) {
+ statusList.push({
+ type: exports.COMPATIBILITY_STATUS_TYPES.ERROR,
+ message: exports.COMPATIBILITY_STATUS_MESSAGES.CONTAINS_IMAGE
+ });
+
+ } else if (exports.isDriveLocked(drive)) {
+ statusList.push({
+ type: exports.COMPATIBILITY_STATUS_TYPES.ERROR,
+ message: exports.COMPATIBILITY_STATUS_MESSAGES.LOCKED
+ });
+
+ } else if (!_.isNil(drive) && !exports.isDriveLargeEnough(drive, image)) {
+ statusList.push({
+ type: exports.COMPATIBILITY_STATUS_TYPES.ERROR,
+ message: exports.COMPATIBILITY_STATUS_MESSAGES.TOO_SMALL
+ });
+
+ } else {
+
+ if (exports.isSystemDrive(drive)) {
+ statusList.push({
+ type: exports.COMPATIBILITY_STATUS_TYPES.WARNING,
+ message: exports.COMPATIBILITY_STATUS_MESSAGES.SYSTEM
+ });
+ }
+
+ if (!_.isNil(drive) && !exports.isDriveSizeRecommended(drive, image)) {
+ statusList.push({
+ type: exports.COMPATIBILITY_STATUS_TYPES.WARNING,
+ message: exports.COMPATIBILITY_STATUS_MESSAGES.SIZE_NOT_RECOMMENDED
+ });
+ }
+ }
+
+ return statusList;
+};
diff --git a/tests/gui/components/drive-selector.spec.js b/tests/gui/components/drive-selector.spec.js
new file mode 100644
index 00000000..e0c1853e
--- /dev/null
+++ b/tests/gui/components/drive-selector.spec.js
@@ -0,0 +1,103 @@
+'use strict';
+
+const _ = require('lodash');
+const m = require('mochainon');
+const angular = require('angular');
+require('angular-mocks');
+
+describe('Browser: DriveSelector', function() {
+
+ beforeEach(angular.mock.module(
+ require('../../../lib/gui/components/drive-selector/drive-selector')
+ ));
+
+ describe('DriveSelectorController', function() {
+
+ let $controller;
+ let $rootScope;
+ let $q;
+ let $uibModalInstance;
+ let DrivesModel;
+ let SelectionStateModel;
+ let WarningModalService;
+ let AnalyticsService;
+
+ let controller;
+
+ beforeEach(angular.mock.inject(function(
+ _$controller_,
+ _$rootScope_,
+ _$q_,
+ _DrivesModel_, _SelectionStateModel_,
+ _WarningModalService_,
+ _AnalyticsService_
+ ) {
+ $controller = _$controller_;
+ $rootScope = _$rootScope_;
+ $q = _$q_;
+ $uibModalInstance = {};
+ DrivesModel = _DrivesModel_;
+ SelectionStateModel = _SelectionStateModel_;
+ WarningModalService = _WarningModalService_;
+ AnalyticsService = _AnalyticsService_;
+ }));
+
+ beforeEach(() => {
+ controller = $controller('DriveSelectorController', {
+ $scope: $rootScope.$new(),
+ $q,
+ $uibModalInstance,
+ DrivesModel,
+ SelectionStateModel,
+ WarningModalService,
+ AnalyticsService
+ });
+ });
+
+ describe('.memoizeImmutableListReference()', function() {
+
+ it('constant true should return memoized true', function() {
+ const memoizedConstTrue = controller.memoizeImmutableListReference(_.constant(true));
+ m.chai.expect(memoizedConstTrue()).to.be.true;
+ });
+
+ it('should reflect state changes', function() {
+ let stateA = false;
+ const memoizedStateA = controller.memoizeImmutableListReference(() => {
+ return stateA;
+ });
+
+ m.chai.expect(memoizedStateA()).to.be.false;
+
+ stateA = true;
+
+ m.chai.expect(memoizedStateA()).to.be.true;
+ });
+
+ it('should reflect different arguments', function() {
+ const memoizedParameter = controller.memoizeImmutableListReference(_.identity);
+
+ m.chai.expect(memoizedParameter(false)).to.be.false;
+ m.chai.expect(memoizedParameter(true)).to.be.true;
+ });
+
+ it('should handle equal angular objects with different hashes', function() {
+ const memoizedParameter = controller.memoizeImmutableListReference(_.identity);
+ const angularObjectA = {
+ $$hashKey: 1,
+ keyA: true
+ };
+ const angularObjectB = {
+ $$hashKey: 2,
+ keyA: true
+ };
+
+ m.chai.expect(memoizedParameter(angularObjectA)).to.equal(angularObjectA);
+ m.chai.expect(memoizedParameter(angularObjectB)).to.equal(angularObjectA);
+ });
+
+ });
+
+ });
+
+});
diff --git a/tests/shared/drive-constraints.spec.js b/tests/shared/drive-constraints.spec.js
index 9d4bbc56..f893305e 100644
--- a/tests/shared/drive-constraints.spec.js
+++ b/tests/shared/drive-constraints.spec.js
@@ -1,6 +1,7 @@
'use strict';
const m = require('mochainon');
+const _ = require('lodash');
const path = require('path');
const constraints = require('../../lib/shared/drive-constraints');
@@ -771,4 +772,233 @@ describe('Shared: DriveConstraints', function() {
});
+ describe('.getDriveImageCompatibilityStatuses', function() {
+
+ beforeEach(function() {
+ if (process.platform === 'win32') {
+ this.mountpoint = 'E:';
+ this.separator = '\\';
+ } else {
+ this.mountpoint = '/mnt/foo';
+ this.separator = '/';
+ }
+
+ this.drive = {
+ device: '/dev/disk2',
+ name: 'My Drive',
+ protected: false,
+ system: false,
+ mountpoints: [
+ {
+ path: this.mountpoint
+ }
+ ],
+ size: 4000000000
+ };
+
+ this.image = {
+ path: path.join(__dirname, 'rpi.img'),
+ size: {
+ original: this.drive.size - 1,
+ final: {
+ estimation: false
+ }
+ }
+ };
+ });
+
+ const expectStatusTypesAndMessagesToBe = (resultList, expectedTuples) => {
+
+ // Sort so that order doesn't matter
+ const expectedTuplesSorted = _.sortBy(_.map(expectedTuples, (tuple) => {
+ return {
+ type: constraints.COMPATIBILITY_STATUS_TYPES[tuple[0]],
+ message: constraints.COMPATIBILITY_STATUS_MESSAGES[tuple[1]]
+ };
+ }), [ 'message' ]);
+ const resultTuplesSorted = _.sortBy(resultList, [ 'message' ]);
+
+ m.chai.expect(resultTuplesSorted).to.deep.equal(expectedTuplesSorted);
+ };
+
+ describe('given there are no errors or warnings', () => {
+
+ it('should return an empty list', function() {
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, {
+ path: '/mnt/disk2/rpi.img',
+ size: 1000000000
+ });
+
+ m.chai.expect(result).to.deep.equal([]);
+ });
+
+ });
+
+ describe('given the drive contains the image', () => {
+
+ it('should return the contains-image error', function() {
+ this.image.path = path.join(this.mountpoint, 'rpi.img');
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, this.image);
+ const expectedTuples = [ [ 'ERROR', 'CONTAINS_IMAGE' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given the drive is a system drive', () => {
+
+ it('should return the system drive warning', function() {
+ this.drive.system = true;
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, this.image);
+ const expectedTuples = [ [ 'WARNING', 'SYSTEM' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given the drive is too small', () => {
+
+ it('should return the too small error', function() {
+ this.image.size.final.value = this.drive.size + 1;
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, this.image);
+ const expectedTuples = [ [ 'ERROR', 'TOO_SMALL' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given the drive is locked', () => {
+
+ it('should return the locked drive error', function() {
+ this.drive.protected = true;
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, this.image);
+ const expectedTuples = [ [ 'ERROR', 'LOCKED' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given the drive is smaller than the recommended size', () => {
+
+ it('should return the smaller than recommended size warning', function() {
+ this.image.recommendedDriveSize = this.drive.size + 1;
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, this.image);
+ const expectedTuples = [ [ 'WARNING', 'SIZE_NOT_RECOMMENDED' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given the image is null', () => {
+
+ it('should return an empty list', function() {
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, null);
+
+ m.chai.expect(result).to.deep.equal([]);
+ });
+
+ });
+
+ describe('given the drive is null', () => {
+
+ it('should return an empty list', function() {
+ const result = constraints.getDriveImageCompatibilityStatuses(null, this.image);
+
+ m.chai.expect(result).to.deep.equal([]);
+ });
+
+ });
+
+ describe('given a locked drive and image is null', () => {
+
+ it('should return locked drive error', function() {
+ this.drive.protected = true;
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, null);
+ const expectedTuples = [ [ 'ERROR', 'LOCKED' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given a system drive and image is null', () => {
+
+ it('should return system drive warning', function() {
+ this.drive.system = true;
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, null);
+ const expectedTuples = [ [ 'WARNING', 'SYSTEM' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given the drive contains the image and the drive is locked', () => {
+
+ it('should return the contains-image drive error by precedence', function() {
+ this.drive.protected = true;
+ this.image.path = path.join(this.mountpoint, 'rpi.img');
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, this.image);
+ const expectedTuples = [ [ 'ERROR', 'CONTAINS_IMAGE' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given a locked and too small drive', () => {
+
+ it('should return the locked error by precedence', function() {
+ this.drive.protected = true;
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, this.image);
+ const expectedTuples = [ [ 'ERROR', 'LOCKED' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given a too small and system drive', () => {
+
+ it('should return the too small drive error by precedence', function() {
+ this.image.size.final.value = this.drive.size + 1;
+ this.drive.system = true;
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, this.image);
+ const expectedTuples = [ [ 'ERROR', 'TOO_SMALL' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+
+ describe('given a system drive and not recommended drive size', () => {
+
+ it('should return both warnings', function() {
+ this.drive.system = true;
+ this.image.recommendedDriveSize = this.drive.size + 1;
+
+ const result = constraints.getDriveImageCompatibilityStatuses(this.drive, this.image);
+ const expectedTuples = [ [ 'WARNING', 'SIZE_NOT_RECOMMENDED' ], [ 'WARNING', 'SYSTEM' ] ];
+
+ expectStatusTypesAndMessagesToBe(result, expectedTuples);
+ });
+
+ });
+ });
+
});