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.