Po krótkiej przerwie przyszedł czas na konkretne programowanie w naszym projekcie. Ale jak wiadomo dobre programowanie należałoby zacząć od … no właśnie od czego jak nie od testów jednostkowych. W naszym projekcie do testowania naszych skryptów JS użyjemy Karmy. Ale po koleji…
Karma – instalacja i konfiguracja
Karma tak jak i inne przez nas używane pakiety jest napisana w JS i można ją zainstalować w bardzo prosty sposób przy użyciu pakietów nodejs. Dodajmy do naszego pliku package.json nastepujące linijki:
"grunt-karma": "0.8.3",
"karma-jasmine": "0.1.5",
"karma-phantomjs-launcher": "0.1.2"
Teraz pozostaje wykonać polecenie npm install, aby dograć brakujące pakiety. W ten sposób zainstalowaliśmy Karmę, która będzie stanowiła silnik naszych testów, jej plugin jasmine, który umożliwi nam pisanie testów oraz phantomjs-launcher, który uruchomi testy poza środowiskiem przeglądarki (można zainstalować także inne pluginy przeglądarek: Chrome, FireFox, Opera, Safari czy IE).
Przyszedł więc czas na to, aby skonfigurować nasze środowisko testowe. Załóżmy, że wszystkie testy będziemy trzymać w katalogu: tests/unit, a konfigurację Karmy w pliku tests/karma.config.js, który przedstawia się następująco:
// tests/karma.config.js
module.exports = function(config) { config.set({ frameworks: ['jasmine'], files: [ '../client/components/angular/*.js', '../client/components/angular-route/*.js', '../client/components/angular-mocks/*.js', '../client/components/lodash/*.js', '../client/src/js/app/app.js', '../client/src/js/app/controllers.js', 'unit/**/*.test.js' ], reporters: ['progress'], preprocessors: { '../../client/js/src/app/**/*.js': ['coverage'] }, coverageReporter: { type : 'html', dir : 'coverage/' },
port: 9876, colors: true, browsers: ['PhantomJS'], autoWatch: true,
singleRun: false }); };
Teraz krótko omówię poszczególne opcje konfiguracyjne (użyte przeze mnie powyżej nie stanowią pełnej listy możliwości):
- frameworks – lista pluginów, które chcemy użyć, w naszym przypadku jest to jasmine, w której będziemy pisać poszczególne testy
- files – lista plików które składają się na naszą aplikację, niezbędne biblioteki zewnętrzne, jak i pliki z testami jednostkowymi
- reporters – lista raportów jakie chcemy uzyskać (progress – pokaże tylko informację ile testów pozytywnie przeszło), dostępne są także inne (w przyszłości skorzystamy z coverage, którego tyczą się w szczególności poniższe dwie opcje)
- preprocessors – lista obserwowanych plików w kontekście danego testu
- coverageReporter – sposób zaraportowania pokrycia kodu (u nas będzie html umiejscowiony w katalogu tests/coverage)
- port – port na którym jest uruchamiana Karma
- colors – kolorowanie składni pojawiających się komunikatów
- browsers – w jakich przeglądarkach mają zostać uruchomione nasze tetsy
- autoWatch – czy nasłuchiwać zmian na plikach
- singleRun – czy uruchomić testy jednokrotnie
Ostatnie dwie opcje pozwolą nam na jednokrotnie uruchomić polecenie: grunt karma, a w trakcie zmian na naszych plikach źródłowych karma będzie samoczynnie uruchamiać testy.
Pozostało nam jeszcze dodać tylko do pliku Gruntfile.js kilka linijek:
karma: {
unit: {
configFile: 'tests/karma.config.js'
}
},
...
grunt.loadNpmTasks('grunt-karma');
i mamy już w pełni skonfigurowane środowisko testowe. Pozostaje napisać już tylko testy jednostkowe, ale o tym za chwilę.
Testy jednostkowe
Na początek utwórzmy plik tests/unit.controller.test.js w którym przetestujemy podstawowe ustawienia naszego kontorlera:
describe('controllers.js', function () { beforeEach(module('filemanager')); describe('MainCtrl:', function () { }); });
Na początku pliku załadujmy moduł filemanager abyśmy mogli korzystać z naszego kodu. Ponieważ w pliku controllers.js może być w przyszłości wiele kontrolerów, to dla przejrzystości naszych testów utwórzmy sekcję MainCtrl, w której zawrzemy wszystkie nasze testy jednostkowe.
Będziemy także potrzebować funkcji, która utworzy nam nasz kontroler i wstrzyknie do niego wszystkie potrzebne zależności. Dobrze byłoby, aby tworzył się on przed każdym testem jednostkowym na nowo.
describe('MainCtrl:', function () {
var $scope, $rootScope, createMainCtrl; beforeEach(inject(function($injector){ var $controller = $injector.get('$controller'); $rootScope = $injector.get('$rootScope'); $scope = $rootScope.$new(); createMainCtrl = function(){ return $controller('MainCtrl', { '$scope': $scope, '$http': $injector.get('$http') }) } }));
beforeEach(function(){ createMainCtrl(); });
});
W pierwszej kolejności sprawdźmy nasze podstawowe dane (listę plików i forlderów) czy mają wartość pustej tablicy.
beforeEach(function(){ createMainCtrl(); }); describe('primary values', function () { it('dirs should be empty array', function () { expect($scope.dirs.length).toBe(0); }); it('files should be empty array', function () { expect($scope.files.length).toBe(0); }); });
Jak widać pisanie testów jednostkowych nie jest takie trudne. Jeśli ktoś chciałby bardziej zgłębić te tajniki to odsyłam do tutorialu AngularJS lub do dokumentacji Jasmine.
Serwisy
W AngularJS istnieje kila typów serwisów: constat, value, service, factory i provider. Każdy z nich wyróżnia się specyficzną konstrukcją i cechami, jednak jest jedna rzecz, która je wszystkie łączy – każdy jest singletonem. Więcej szczegółów na ten temat znajdziecie w artykule Understanding Service Types.
Wszystkie serwisy dzięki temu, że są singletonami świetnie sprawują się jako pośrednik wymiany danych między kontrolerami i w takiej roli często się pojawiają w różnych aplikacjach.
Na początku naszej aplikacji stworzymy dwa serwisy:
- FileTypes – będzie zawierał pogrupowane typy mime plików, które będą odpowiadały filtrom znajdującym się w górnej części naszej aplikacji (grafika, wideo, audio, archiwa)
- FileIcons – będzie udostępniał funkcję zwracającą url do miniatury typu pliku, który nie jest plikiem graficznym
W tym celu stwórzmy plik services.js o następującej treści:
angular.module('filemanager') .service('FileTypes', function(){ this.images = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif', 'image/png']; this.audio = ['audio/mpeg', 'audio/x-ms-wma', 'audio/vnd.rn-realaudio', 'audio/x-wav']; this.video = ['video/mpeg', 'video/mp4', 'video/quicktime', 'video/x-ms-wmv']; this.archive = ['application/zip']; }) .service('FileIcons', function(){ this.imagesExtensions = ['aac', 'ai', 'aiff', 'avi', '_blank', 'bmp', 'c', 'cpp', 'css', 'dat', 'dmg', 'doc', 'dotx', 'dwg', 'dxf', 'eps', 'exe', 'flv', 'gif', 'h', 'hpp', 'html', 'ics', 'iso', 'java', 'jpg', 'js', 'key', 'less', 'mid', 'mp3', 'mp4', 'mpg', 'odf', 'ods', 'odt', 'otp', 'ots', 'ott', '_page', 'pdf', 'php', 'png', 'ppt', 'psd', 'py', 'qt', 'rar', 'rb', 'rtf', 'sass', 'scss', 'sql', 'tga', 'tgz', 'tiff', 'txt', 'wav', 'xls', 'xlsx', 'xml', 'yml', 'zip']; }) ;
O ile serwis FileType nie wymaga napisania żadnych testów, o tyle FileIcons już tak. Wiemy, że ma zawierać funkcję zwracającą url do ikony reprezentującej dany typ pliku. Napiszmy zatem najpierw odpowiedni test jednostkowy, który sprawdzi poprawność naszego kodu.
// tests/unit/services.test.js describe('services.js', function () { beforeEach(module('filemanager')); describe('FileIconsService', function () { var fileIcons; beforeEach(inject(function($injector){ fileIcons = $injector.get('FileIcons'); })); it('should return /data/icons/_blank.png for file name equal moj.jar', function () { expect(fileIcons.getIconPath('moj.jar')).toEqual('/data/icons/_blank.png'); }); it('should return /data/icons/pdf.png for file name equal moj.pdf', function () { expect(fileIcons.getIconPath('moj.pdf')).toEqual('/data/icons/pdf.png'); }); }); });
Mając tak przygotowane testy możemy przystąpić do napisania funkcji getIconPath(filename). Jej zadaniem będzie sprawdzić czy rozszerzenie pliku znajduje się na liście dostępnych ikon i zwrócić odpowiednią ścieżkę. Jeśli rozszerzenie nie zostanie znalezione, zwraca ikonę _blank.png.
service('FileIcons', function(){ this.imagesExtensions = ['aac', 'ai', 'aiff', 'avi', '_blank', 'bmp', 'c', 'cpp', 'css', 'dat', 'dmg', 'doc', 'dotx', 'dwg', 'dxf', 'eps', 'exe', 'flv', 'gif', 'h', 'hpp', 'html', 'ics', 'iso', 'java', 'jpg', 'js', 'key', 'less', 'mid', 'mp3', 'mp4', 'mpg', 'odf', 'ods', 'odt', 'otp', 'ots', 'ott', '_page', 'pdf', 'php', 'png', 'ppt', 'psd', 'py', 'qt', 'rar', 'rb', 'rtf', 'sass', 'scss', 'sql', 'tga', 'tgz', 'tiff', 'txt', 'wav', 'xls', 'xlsx', 'xml', 'yml', 'zip']; this.getIconPath = function(fileName){ var ext = fileName.substr(_.lastIndexOf(fileName, '.') + 1); if(this.imagesExtensions.indexOf(ext) > -1) { return '/data/icons/' + ext + '.png'; } else { return '/data/icons/_blank.png'; } } })
Być może w tej chwili nie do końca widać potrzebę tworzenia takich serwisów, ale obiecuję, że w późniejszej części bardzo się nam przydadzą.
Modele
Modele, co to takiego? Właściwie ciężko na to pytanie odpowiedzieć. AngularJS jest uważany za framework typu MVM (Model View Model) w którym ciężko wyizolować model, gdyż często przeplata się on z funkcjonalnością kontrolera. Przeciwieństwem są frameworki MVC (Model View Controller), gdzie Model jest specjalnie wyizolowaną częścią naszego kodu.
Ja w swoich aplikacjach staram się mimo wszystko wyodrębnić Modele, dzięki czemu uzyskuje się ładniej wyglądający kod i większą jego elastyczność (łatwiej też konstruować testy).
Także tutaj postanowiłem stworzyć dwa modele:
- FileObj – model pliku z danego katalogu
- DirObj – model katalogu
Na początek stwórzmy kilka prostych testów:
// tests/unit/services.test.js describe('models.js', function () { var FileObj, DirObj; beforeEach(module('filemanager')); beforeEach(inject(function($injector){ FileObj = $injector.get('FileObj'); DirObj = $injector.get('DirObj'); })); describe('FileObj:', function () { var imageFileObj = { "id": 21, "name": "Dino 8", "src": "/data/images/IMG_5583.JPG", "mime": "image/jpg" } pdfFileObj = { "id": 50, "name": "Przykładowy PDF", "src": "/data/pdf/przykladowy_pdf.pdf", "mime": "application/pdf" } ; describe('functions:', function () { it('setData: should set data for new object correctly', function () { var fileObj = new FileObj({id: 7, name: 'Nowy plik', mime: 'image/jpg'}); expect(fileObj.id).toBe(7); expect(fileObj.name).toEqual('Nowy plik'); }); it('isImage: should return true for mime: image/jpg', function () { var fileObj = new FileObj(imageFileObj); expect(fileObj.isImage()).toBeTruthy(); }); it('isImage: should return false for mime: application/pdf', function () { var fileObj = new FileObj(pdfFileObj); expect(fileObj.isImage()).toBeFalsy(); }); }); }); describe('DirObj:', function () { describe('functions:', function () { describe('setData:', function () { it('should set data for new object correctly', function () { var dirObj = new DirObj({id: 11, name: 'Nowy katalog'}); expect(dirObj.id).toBe(11); expect(dirObj.name).toEqual('Nowy katalog'); }); }); }); }); });
Myślę, że nie będziecie mieli większych problemów z przeanalizowaniem tych testów. Jednak na wszelki wypadek, krótko wytłumaczę o co chodzi. Oba obiekty powinny mieć funkcję setData(data), która przyjmie dane otrzymane z serwera (JSON) i przekaże je jako właściwości naszego obiektu. Dodatkowo obiekt pliku (FileObj) musi posiadać funkcję isImage(), zwracającą true/false, jeśli obiekt jest grafiką lub nie.
Skoro wiemy już jak mają wyglądać nasze obiekty to przyszedł czas na ich napisanie:
// src/js/app/modules.js angular.module('filemanager') .factory('FileObj', ['FileTypes', 'FileIcons', function(FileTypes, FileIcons){ function FileObj(data) { this.image = false; this.icon = false; this.setData(data); } FileObj.prototype = { setData: function(data){ angular.extend(this, data); this.image = (FileTypes.images.indexOf(this.mime) > -1) ? true : false; if(!this.image) { this.icon = FileIcons.getIconPath(this.src); } }, isImage: function(){ return this.image; } } return FileObj; }]) .factory('DirObj', function(){ function DirObj(data) { this.setData(data); } DirObj.prototype = { setData: function(data){ angular.extend(this, data); } } return DirObj; }) ;
Jak możemy zauważyć każdy z naszych obiektów jest serwisem typu factory, zwracają one konstruktor obiektów, dzięki czemu używając słowa kluczowego new możemy tworzyć wiele instancji takiego obiektu.
FileObj w swoim konstruktorze ustawia dwie dodatkowe właściwości:
- image – true/false – czy jest plikiem graficznym
- icon – false/string – ścieżka do ikony reprezentującej typ pliku jeśli plik nie jest plikiem graficznym
Myślę, że reszta kodu nie wymaga dodatkowego komentarza.
Kontroler i szablon HTML
Ponieważ przygotowaliśmy sobie nieco kodu w postaci serwisów i modeli, to spróbujmy go wykorzystać w naszym kontrolerze i szablonie HTML.
W pierwszej kolejności użyjmy obiektów FIleObj i DirObj aby zastąpiły nasze dotychczasowe wartości w tablicach $scope.files i $scope.dir. Zaznaczmy przy tym, że zmieniła się nieco struktura naszego pliku /data/directory.json. Każdy element tablicy files ma teraz dodatkową cechę mime i wygląda następująco:
{ "id": 50, "name": "Przykładowy PDF", "src": "/data/pdf/przykladowy_pdf.pdf", "mime": "application/pdf" }
Zmodyfikujmy zapytanie wczytujące dane:
$http.get('/data/directory.json') .success(function(data){ data.dirs.forEach(function(dirData){ $scope.dirs.push(new DirObj(dirData)); }); data.files.forEach(function(fileData){ $scope.files.push(new FileObj(fileData)); }); }) ;
nie zapomnijmy przy tym o dodaniu do deklaracji kontrolera odpowiednich parametrów
.controller('MainCtrl', ['$scope', '$http', 'DirObj', 'FileObj', function($scope, $http, DirObj, FileObj){ … })
Taka zmiana pozwoli nam zmodyfikować nieco kod HTML, tak aby dla plików typu graficznego wyświetlał miniaturkę, a dla innych typów plików odpowiednią ikonę.
<div ng-repeat="file in files" class="thumb thumb-file img-thumbnail">
<img ng-if="file.isImage()" ng-src="{{ file.src }}" class="thumb-image" >
<img ng-if="!file.isImage()" ng-src="{{ file.icon }}" class="thumb-image thumb-icon" >
<div class="thumb-name" data-ng-bind="file.name"></div>
</div>
Wprowadźmy także w przypadku plików i katalogów sortowanie po nazwie:
<div ng-repeat="dir in dirs | orderBy:'name'" class="thumb thumb-folder img-thumbnail">
…
</div>
<div ng-repeat="file in files | orderBy:'name'" class="thumb thumb-file img-thumbnail">
…
</div>
…
<div class="col-sm-4 col-md-4 col-md-push-1 col-sm-push-1">
<div class="input-group">
<input ng-model="search" type="text" class="form-control" placeholder="Filtruj">
<span class="btn btn-default input-group-addon"><i class="fa fa-search"></i></span>
</div>
</div>
<div ng-repeat="dir in dirs | filter:{'name': search } | orderBy:'name'" class="thumb thumb-folder img-thumbnail">
…
</div>
<div ng-repeat="file in files | filter:{'name': search } | orderBy:'name'" class="thumb thumb-file img-thumbnail">
…
</div>
Na koniec pozostało nam tylko ożywić przyciski umożliwiające filtrowanie po typie pliku. Dodajmy zatem do kontrolera pole przechowującego wartość obecnie wybranego filtra (false jeśli żaden nie został wybrany), a także zmienną, która przez referencję odwołuje się do serwisu FileTypes.
// clients/src/js/controllers.js /** * Current file type filter name (false = off) * @type {boolean|string} */ $scope.fileTypeFilter = false; /** * List file mime types * @type {{images: Array, audio: Array, video: Array, archive: Array}} */ $scope.fileTypes = FileTypes;
Dodatkowo dodajmy też funkcję, która umożliwi nam ustawienie obecnego filtru:
/** * Change filter file type name * * @param filterName */ $scope.setFilterType = function(filterName){ if(filterName && filterName !== false && $scope.fileTypes[filterName]) { $scope.fileTypeFilter = filterName; } else { $scope.fileTypeFilter = false; } };
Oczywiście wszystko musi być poparte testami jednostkowymi:
describe('primary values', function () { … it('default fileTypeFilter should have been false', function () { expect($scope.fileTypeFilter).toBeFalsy(); }); }); describe('functions:', function () { describe('setFilterType:', function () { it('should set type images', function () { $scope.setFilterType('images'); expect($scope.fileTypeFilter).toEqual('images'); }); it('should clear filter', function () { $scope.setFilterType(); expect($scope.fileTypeFilter).toBeFalsy(); }); it('use unknown filter name should clear filter', function () { $scope.setFilterType('photoshop'); expect($scope.fileTypeFilter).toBeFalsy(); }); }); });
Teraz jeszcze drobne zmiany w naszym pliku HTML
<div class="btn-group">
<button ng-click="setFilterType(false)" ng-class="{'active': !fileTypeFilter}" class="btn btn-default" title="Wszystkie typy plików"><i class="fa fa-file-o"></i></button>
<button ng-click="setFilterType('images')" ng-class="{'active': fileTypeFilter == 'images'}" class="btn btn-default" title="Zdjęcia"><i class="fa fa-picture-o"></i></button>
<button ng-click="setFilterType('audio')" ng-class="{'active': fileTypeFilter == 'audio'}" class="btn btn-default" title="Audio"><i class="fa fa-music"></i></button>
<button ng-click="setFilterType('video')" ng-class="{'active': fileTypeFilter == 'video'}" class="btn btn-default" title="Wideo"><i class="fa fa-video-camera"></i></button>
<button ng-click="setFilterType('archive')" ng-class="{'active': fileTypeFilter == 'archive'}" class="btn btn-default" title="Archiwa"><i class="fa fa-archive"></i></button>
</div>
Filtry
AngularJS posiada możliwość definiowania własnych filtrów i używania ich w kodzie HTML poprzez „| nazwa_filtru” lub w dowolnym innym miejscu poprzez użycie serwisu $filter.
My stworzymy sobie filtr, którego zadaniem będzie przefiltrowanie zadanej tablicy pod kątem plików o zadanych typach mime. Filtr powinien spełniać następujące testy:
// tests/unit/filters.test.js describe('filters.js', function () { beforeEach(module('filemanager')); describe('fileMimeType:', function () { var fileMimeFilter, filesList = [ { "id": 1, "name": "Dino 1", "src": "/data/images/IMG_5549.JPG", "mime": "image/jpg" }, { "id": 10, "name": "Dino 2 - PDF dokument", "src": "/data/pdf/some.pdf", "mime": "application/pdf" }, { "id": 11, "name": "Dino 3", "src": "/data/images/IMG_5559.JPG", "mime": "image/jpg" }, { "id": 2, "name": "Dino 4", "src": "/data/images/IMG_5565.JPG", "mime": "image/jpg" }, { "id": 4, "name": "Dino 5", "src": "/data/images/IMG_5567.JPG", "mime": "image/jpg" } ], imagesMimes = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif', 'image/png'], pdfMimes = ['application/pdf'], $filter ; beforeEach(inject(function($injector){ $filter = $injector.get('$filter'); fileMimeFilter = $filter('fileMime'); })); it('images mimes should return 4 items', function () { expect(fileMimeFilter(filesList, imagesMimes).length).toBe(4); }); it('pdf mimes should return 1 item', function () { expect(fileMimeFilter(filesList, pdfMimes).length).toBe(1); }); it('empty mime list should return 5 items', function () { expect(fileMimeFilter(filesList, []).length).toBe(5); }); it('undefined mime list should return 5 items', function () { expect(fileMimeFilter(filesList).length).toBe(5); }); }); });
Skoro wiemy co ma robić nasz filtr wystraczy teraz napisać krótki kawałek kodu, który będzie to realizował
// client/src/js/filters.js angular.module('filemanager') .filter('fileMime', function(){ return function(filesList, fileMimeTypesList){ var files = []; if(typeof fileMimeTypesList === 'undefined' || fileMimeTypesList.length === 0) { return filesList; } filesList.forEach(function(file){ if(fileMimeTypesList.indexOf(file.mime) > -1) { files.push(file); } }); return files; }; }) ;
a następnie użyć go w naszym pliku HTML
<div ng-repeat="file in files | fileMime:(fileTypes[fileTypeFilter] || []) | filter:{'name': search } | orderBy:'name'" class="thumb thumb-file img-thumbnail">
…
</div>
Dzięki temu nasz mechanizm powinien zacząć działać tak jak byśmy tego chcieli.
CSS
Na sam koniec pozostało nieco drobnych zmian w samym plik mian.less, tak by nasza cała aplikacja prezentowała się w miarę sensowny sposób (proszę mi wybaczyć wszelki design – nie jestem grafikiem).
Dodałem takie efekty jak rozjaśnienie i zmiana koloru ramki przy najechaniu na miniaturkę, wypozycjonowanie ikony reprezentującej dany typ pliku i inne drobne poprawki.
@containerClass: filemanager; @thumbWidth: 180px; @thumbHeight: 140px; @thumbNameHeight: 30px; .@{containerClass} { .nav-row { margin: 0 0 10px 0; div:first-child { margin-left: 0; padding-left: 0; } div:last-child { margin-right: 0; padding-right: 0; } } .btn { height: 34px; // Ikonki w przyciskach .fa { margin-right: 5px; &:last-child { margin-right: 2px; } } } .main-panel { .thumb { width: @thumbWidth; height: @thumbHeight; margin: 20px; cursor: pointer; &:hover { border: 1px solid red; opacity: 0.7; } .thumb-image { height: @thumbHeight - @thumbNameHeight - 10; text-align: center; .fa { font-size: @thumbHeight - @thumbNameHeight - 10; } } .thumb-name { height: @thumbNameHeight; line-height: @thumbNameHeight; background-color: #eeeeee; border-radius: 3px; padding: 0 3px; overflow: hidden; white-space:nowrap; text-overflow: ellipsis; text-align: center; } &.thumb-folder { text-align: center; .thumb-image { &.fa { font-size: @thumbHeight - @thumbNameHeight; } } } &.thumb-file { .thumb-image { width: @thumbWidth - 10; border-radius: 3px; &.thumb-icon { width: 80px; height: 80px; margin: 10px (@thumbWidth - 80)/2; } } } } } }
Teraz powinieneś ujrzęć taki sam stan aplikacji jak w wersji 0.0.2 na githubie.