Член місцевої ради в Україні декларує 124 BTC і 500 акцій Tesla

BTC

Урядовець Рівненської області володіє майже 8 мільйонами доларів криптовалюти та 500 акціями Tesla, йдеться у його декларації. Ця новина з’явилася через те, що ще одному українському депутату в Києві було важко довести володіння криптовалютами у своїй заяві про активи.

Українські чиновники декларують криптоактиви, не надавши підтвердження права власності

Криптоінвестиції останніми роками користуються все більшою популярністю серед українських політиків, показали обов’язкові відомості про активи. За останніми даними ЗМІ, депутат Віровської сільської ради в західній Рівненській області задекларував наявність 124 BTC на суму майже 8 мільйонів доларів у сьогоднішніх цінах.

Володимир Пачесний придбав монети на початку 2013 року за 73 920 гривень на момент покупки, трохи більше 2800 доларів, за останнім курсом завищеної української фіатної валюти. У 2019 році 36-річний місцевий депутат також купив 500 акцій американської компанії з електромобілів Tesla, яка підняла ціни на криптовалюту цього року своїми оголошеннями про прийняття та володіння біткойнами.

Пачесний не є ані першим, ані найбагатшим інвестором у криптовалюту серед чиновників в Україні, який зарекомендував себе як лідер із впровадження криптовалют. У квітневому звіті було оприлюднено, що державні службовці та політики володіли 46 351 BTC на той час на суму 75 мільярдів гривень (більше 2,6 мільярдів доларів), причому найбільший запас у депутата Дніпровської міської ради — 18 000 BTC .

У своїх деклараціях за 2020 рік публічні діячі визнали, що володіють загалом 46 351 BTC та різними іншими цифровими валютами, включаючи ETH , LTC , BCH та XMR , згідно з цифрами, зібраними платформою Opendatabot, яка відстежує публічні реєстри в Україні. Однак не всі з них змогли надати необхідні документи, щоб підтвердити, що вони контролюють монети. Цієї весни в Національному агентстві з питань запобігання корупції (НАЗК) пообіцяли перевірити цифри.

Ще один український депутат не може обліковувати власні криптовалюти

У вересні українські ЗМІ повідомляли, що депутат від партії «Слуга народу» президента Зеленського не зміг довести свої цифрові холдинги. Дані подані законодавцем показали, що його дружина, Марія Салтикова, володіла 42 BTC в минулому році , але NAPC сказав , що він не надав жодних документів, що підтверджують достовірність цієї інформації. Депутат лише пояснив, що криптовалюта зберігалася на апаратному гаманці, який зник, коли його автомобіль викрали на початку цього року.

Випадок Гуріна в Україні не поодинокий. Нещодавня стаття порталу «Слово і Діло» показала, що інший депутат Верховної Ради Анна Скороход не надала НАЗК докази того, що вона фактично володіє криптовалютами, про які раніше повідомляла установі.

У своїй останній заяві, Скороход заявила, що вона володіла 44 BTC , 130 ETH і 135 ETC в той час як її чоловік в той час, Олексій Алякін володіє 118 ВТСА, 78 ЕТНА і 350 ETC. Народний депутат України надав «загальні роз’яснення щодо отримання та володіння криптовалютою без надання документів, що підтверджують факти придбання та наявність криптовалюти на кінець звітного періоду».

Вартість криптовалют на момент купівлі оцінювалася в понад 2,7 мільйона гривень (понад 106 тисяч доларів). Скороход повідомила НАЗК, що через її недостатнє знання цифрових грошей усі операції з монетами за її дорученням проводив її чоловік. Вона додала, що не мала доступу до ключів чи будь-яких супровідних документів після їхнього розлучення.

Громадська організація «Віртуальні активи України» та асоціація Blockchain4Ukraine, що об’єднує депутатів з різних політичних фракцій в українському парламенті, запропонували запровадити вимогу, яка підвищить прозорість у звітності чиновників. Обидві організації наполягають на тому, щоб державні службовці вказували в деклараціях адреси своїх криптовалютних гаманців.

Переклад статті Local Council Member in Ukraine Declares 124 BTC and 500 Tesla Shares

Екосистема Binance

Binance

Окрім керування провідною біржою криптовалют у світі, Binance охоплює цілу екосистему.

Поширення свободи грошей і створення інфраструктури для екосистеми блокчейн.

Блокчейн і обмін криптоактивами. Забезпечення безпечної, швидкої та безперебійної торгівлі завдяки BNB. BNB є рідним активом Binance Chain, програмної системи блокчейн, розробленої Binance та спільнотою. BNB має кілька форм корисності та забезпечує екосистему Binance як основний газ.

Інвестиції та збір коштів. Розширення можливостей блокчейн-підприємців, проектів і спільнот.

Дослідження цифрових активів. Надання професійної інформації та аналізу на основі даних.

Підтримка та прийняття. Сприяння прозорим ініціативам для створення громад та розширення глобального доступу.

Приєднуйтесь до спільноти Binance.

Введення в криптографію: симетричне шифрування

Криптографія

Що таке криптографія?

По суті, криптографія – це практика безпечного спілкування при наявності потенційних сторонніх недоброзичливців. Поняття безпечного спілкування має на увазі дві основні складові:

  1. Захист від перехоплення: конфіденційність даних.
  2. Захист від маніпуляції даними: цілісність інформації, що означає, що ніхто не може змінити відправляються вами дані, змусивши одержувача прийняти спотворені дані за дійсні.

Конфіденційність даних досягається за допомогою шифрування, яке може приймати дві форми: симетричну і асиметричну.

  • Симетричне шифрування використовує єдиний ключ, яким повинні обмінюватися всі учасники комунікації.
  • Асиметричне шифрування використовує особисті ключі. У кожного учасника є пара з відкритого і закритого ключів для шифрування і дешифрування повідомлень.

У цій статті ми зосередимося на симетричному шифруванні.

Два типу шифрів

Шифрування забезпечує конфіденційність даних і включає дві важливі складові:

  1. Секретний ключ: в контексті симетричного шифрування можна припустити, що у наших учасників Аліси і Боба є загальний секретний ключ.
  2. Шифр: алгоритми шифрування і дешифрування.

Важливо відзначити, що алгоритми шифрування і дешифрування відомі всім. В секреті зберігається лише ключ.

Є два типи симетричних шифрів: потокові і блокові. Для належного розуміння цих шифрів буде корисно знайомство з бітовими операціями, зокрема з «або» (XOR). Якщо коротко, то це складання двох бітів, при якому, якщо вони різні (0 і 1), результат дорівнює 1, а якщо вони однакові (два нуля або дві одиниці), результат дорівнює 0. У подальшому викладі передбачається, що читач знає, що таке XOR і що цю операцію прийнято позначати знаком ⊕.

Потоковий шифр

Потоковий шифр – це такий симетричний шифр, де над відкритим текстом при шифруванні побитово виконується операція XOR з допомогою ключа. Той же процес використовується для розшифровки. З огляду на характер операції XOR, якщо ми виконаємо її над зашифрованим текстом за допомогою ключа, отримаємо вихідний відкритий текст.

Потоковий шифр
Як влаштований потоковий шифр.

Уважні читачі, напевно, здогадалися, що у ключа і відкритого тексту повинно бути щось спільне, причому дуже важливе. Вірно! Ключ і відкритий текст повинні бути однакової довжини. Це, звичайно, не дуже практично.

Щоб зробити потоковий шифр більш практичним, використовуються генератори псевдовипадкових чисел. Генератор псевдовипадкових чисел – це детермінована процедура , яка на основі входу видає довший псевдовипадковий результат. «Детермінована» означає, що ця процедура при однаковому вході завжди видає один і той же вихід (т. Е. «Abc123» завжди дає «8474f24e0d72e1b949ffd2 …»). Слово «псевдовипадковий» означає, що хоча вихід насправді не випадковий (оскільки він детермінований на основі конкретного входу), він не відрізняється від справжніх випадкових рядків. Іншими словами, якщо мати набір входів і виходів, то неможливо зрозуміти, якого виходу відповідає який був вхід. Якщо використовувати в якості входу загальний секретний ключ, можна отримати більш довгий псевдовипадковий ключ, за допомогою якого буде проведена операція XOR над відкритим текстом такої ж довжини.

Описана реалізація потокового шифру називається «одноразовий блокнот». Дуже важливе її властивість полягає в тому, що ключ можна використовувати тільки ОДИН РАЗ. Якщо він використовується повторно, безпека повідомлень скомпрометована.

Нижче показаний слайд з курсу. PRG (K) позначає псевдовипадкову послідовність, згенерувану з нашого спільного ключа K. Символ ⊕ означає операцію XOR; C – шифротекст; m – повідомлення (відкритий текст).

Введення в криптографію: симетричне шифрування

По суті, слайд показує, що якщо використовувати ключ двічі, то можна провести операцію XOR над двома шіфротекста, що дорівнюватиме операції XOR над двома відкритими текстами. Оскільки зрозумілою людині мовою багато надмірності, тямущий зловмисник за допомогою цієї інформації зможе повністю відновити повідомлення.

Щоб не повторювати ключ одноразового блокнота, але зберегти один загальний секретний ключ, застосовується концепція Нонсо . Нонсо – це довільне число, яке в криптографічній комунікації може використовуватися тільки один раз. Разом з шіфротекстом можна також відправляти НОНС, який буде складатися з секретним ключем, щоб при кожному шифруванні давати інший псевдовипадковий ключ.

Блоковий шифр

Другий тип шифрів – блокові. Блоковий шифр використовує вхід фіксованої довжини і циклічно знову і знову шифрує вихідний текст, використовуючи кожен раз інший ключ ( «раундовий ключ»), поки не видасть шифротекст тієї ж довжини. 3DES і AES – два приклади блочних шифрів, які відповідно використовують вхід з 48 і 128 бітів.

Блоковий шифр

На слайді вище показана базова архітектура блочного шифру. Як можна бачити, для отримання в кожному раунді нового ключа використовується алгоритм розширення ключа. Оригінальний текст (m) шифрується знову і знову, поки не буде отримано шифротекст (C) такої ж довжини.

Заради стислості в цій статті я буду розглядати AES. Незважаючи на історичну значимість DES / 3DES, AES сьогодні більш широко застосовується.

AES

AES побудований як SP-мережа . AES використовує блоки розміром 128 бітів, що дорівнює 16 байтам. 16 байтів записуються як матриця 4 на 4. Така матриця зручна для операцій з даними. У кожному раунді процес наступний:

  1. Проводимо операцію XOR над вихідним повідомленням і першим раундовим ключем (k0) .
  2. Виконуємо підстановку, замінюючи блоки даних іншими на основі певної таблиці (процедура ByteSub) .
  3. Проводимо перестановку, зрушуючи і перемішуючи біти (процедури ShiftRow і MixColumn) .
  4. Процес повторюється 10 раундів.

В останньому раунді опускається процедура MixColumn, проводиться операція XOR з останнім раундовим ключем і отримуємо підсумковий шифротекст. Для розшифровки використовується зворотний процес. Тим, хто бажає заглибитися, можу порадити ознайомитися з мережею Фейстеля, яка використовується в 3DES, щоб порівняти різні блокові шифри.

Після запуску архітектури Westmere компанія Intel стала вбудовувати в свої процесори спеціальні інструкції для оптимізації AES, і незабаром за нею пішла AMD.

Режими блочного шифрування

На відміну від потокових, блокові шифри використовують вхід фіксованої довжини. Очевидно, ми хочемо одночасно обробляти більше 16 байтів даних. Тому далі важливо зрозуміти режими, в яких можна застосовувати блокові шифри до великих обсягів даних. Перший з них – режим електронної кодової книги (ECB). ECB просто розбиває дані на блоки по 16 байтів і виконує AES-шифрування одноманітно. Це можна робити паралельно і дуже швидко. Однак це не дуже безпечно.

Режими блочного шифрування

Причина в тому, що якщо 16-байтові повідомлення повторюються, шифротекст теж буде повторюватися. Так потенційний зловмисник зможе отримати інформацію про наші дані. Можна показати цю уразливість на прикладі зображення, зашифрованого за допомогою ECB. На зображенні нижче видно, що темне волосся і футболка дозволяють розрізнити обриси, за якими можна зрозуміти, що зашифрований знімок якоїсь особи.

Режими блочного шифрування

Важливо, щоб схеми шифрування були семантично стійкими . Семантична стійкість означає, що, якщо є шифротекст, що відповідає одному з двох відкритих текстів, злочінець не може знати з вірогідністю більше 1/2, якого саме відкритого тексту він відповідає. Очевидно, що режим ECB семантично нестійкий. Зашифроване зображення видає багато інформації, що дозволяє здогадатися про інформацію.

ECB – це приклад режиму з одноразовим ключем (тобто, як і в разі одноразового блокнота, ключ не можна використовувати повторно). Ще один, більш безпечний режим з одноразовим ключем – детермінований режим лічильника . Можете пошукати про нього інформацію самостійно, а я перейду до безпечних режимів з багаторазовими ключами.

У режимі зчеплення блоків шифротекста (CBC) кожен 16-байтовий блок відкритого тексту складається за допомогою операції XOR з шіфротекста попереднього блоку, а потім робиться блочне шифрування (AES).

CBC

Починаємо з випадкової векторної ініціалізації (IV) , тобто початкового значення в циклічному процесі. У разі CBC значення IV має бути випадковим (і, отже, непередбачуваним), а значить, унікальним в кожної транзакції. Перший блок шифротекста – це просто незашифрований випадковий IV. Щоб отримати решту шифротекст, спочатку проводиться операція XOR над випадковим IV і першим блоком відкритого тексту (m [0]). Результат шифрується за допомогою раундового ключа k, і отримуємо перший зашифрований блок шифротекста (c [0]). Далі проводиться операція XOR над цим шіфротекстом і наступним блоком відкритого тексту (m [1]), результат шифрується раундовим ключем k, і отримуємо наступний блок шифротексту (c [1]). Процес триває, поки не будуть зашифровані всі блоки.

CBC

Для розшифровки застосовується зворотний процес.

Важлива складова CBC-шифрування – це непередбачуваний випадковий IV. Якщо IV буде передбачуваним, то наша схема шифрування стане вразливою до атаки на основі підібраного відкритого тексту . Атака на основі підібраного відкритого тексту (CPA) має на увазі, що зловмисник може отримати шіфротекст довільних відкритих текстів і з їх допомогою розкрити інформацію у зашифрованих повідомленнях. Отже, непередбачуваний IV забезпечує захист від CPA .

Спробую пояснити, як може виглядати така атака. Провести CPA при наявності передбачуваного IV можна через властивості XOR. Результат операції XOR над двома однаковими значеннями (наприклад, 0101 ⊕ 0101) завжди буде дорівнює нулю. Тому, якщо ви підозрюєте, що шифротекст c [0] відповідає певному відкритого тексту m [0], можна перевірити гіпотезу за допомогою передбачуваного IV. Якщо відкритий текст зашифрували за допомогою IV1 (c [0] = E (k, m [0] ⊕ IV1)), то можна зашифрувати новий відкритий текст і подивитися, співпаде чи результат з c [0]. Оскільки можна передбачити, що IV буде IV2, ви робите m [0] ⊕ IV1 ⊕ IV2. CBC виконає операцію XOR над цим входом і наступним IV (IV2): c [1] = E (k, m [0] ⊕ IV1 ⊕ IV2 ⊕ IV2). Отже, два IV2 взаємно знищуються, і ми знову шифруємо E (k, IV1 ⊕ m [0]), що, знову ж таки, дасть c [0],

Нарешті, хотілося б розглянути ще один режим блокового шифрування: рандомізований режим лічильника (CTR). Це останній, самий безпечний режим, і він також більш ефективний, ніж CBC.

рандомізований режим лічильника

Рандомізований режим лічильника також використовує випадковий IV, але тут він служить іншій меті. Наш ключ складається (наприклад, за допомогою AES) з ітерованих версій нашого IV: наприклад, в кожній ітерації можна збільшувати IV на одиницю, щоб результат не повторювався. Ми робимо це, поки не отримаємо ключ такої ж довжини, як наш відкритий текст. Тепер, як і в випадку потокового шифру з одноразовим ключем, ми проводимо операцію XOR над нашим відкритим текстом і псевдовипадковим ключем, щоб отримати шифротекст. Якщо у вашого комп’ютера є кілька AES-процесор, то це дуже ефективно, так як може виконуватися паралельно. У CBC кожен шифротекст залежить від попереднього блоку, тому обчислювати його паралельно неможливо.

Щоб отримати псевдовипадковий ключ з складання IV і нашого секретного ключа, блоковий шифр не обов’язковий. Блокові шифри повинні бути оборотні. Якщо уважно розглянути механізм рандомізованого режиму лічильника, то можна помітити, що для дешифрування не потрібно виконувати в зворотному порядку F (k, IV). З огляду на властивості XOR, досить взяти той же псевдовипадковий ключ і скласти його за допомогою XOR з нашим шіфротекста. Тобто для дешифрування потрібно повторити операцію, а не провести її в зворотному порядку.

Якщо виражатися абстрактно, це означає, що процедура, яку ми використовуємо для складання нашого секретного ключа і IV (F (k, IV)) повинна бути псевдослучайной функцією (PRF), а не псевдослучайной перестановкою ( PRP). Насправді ми стикалися з цими концепціями в цій статті. І PRF, і PRP – це детерміновані процедури, які при конкретному вході дають псевдовипадковий вихід (AES, XOR). Однак PRP має сувора вимога оборотності. Власне кажучи, поняття PRP і блоковий шифр (наприклад, AES) часто використовуються як синоніми. Але PRF НЕ повинна бути оборотною.

Отже, на цьому закінчується наш огляд симетричного шифрування. Ми розглянули потокові і блокові шифри. Потім, оскільки блокові шифри одночасно здатні обробляти лише 16 байтів, ми поговорили про режими їх застосування до великих відкритих текстів. Нарешті, ми також пояснили різницю між PRP і PRF.

Переклад статті Cryptography 101: Symmetric Encryption

Фінансування приватного блокчейну прискорюється в міру того, як компанії гоняться задовольнити нові потреби

Фінансування приватного блокчейну

Блокчейн-операції все частіше охоплюють більш традиційне фінансування з відомих венчурних фондів, щоб скористатися перевагами цих стратегічних зацікавлених сторін, замість краудфандингових шляхів, популяризованих під час останньої хвилі хайпу блокчейн.

Огляд фінансування: Concordium, StakeWise та Automata виявляють приватний капітал від провідних фондів

У значний відхід від останньої крипто-стартап-хвилі, зосередженої на залученні державного капіталу у формі первинних пропозицій монет (ICO), продажу токенів та запусків бірж, сьогоднішні прогресивні ініціативи блокчейну все частіше використовують приватний капітал з венчурних фондів. Хоча присуджені суми не затьмарюють мільярди, залучені проектами у попередні роки, останні події є позитивними для компаній, фондів та користувачів загалом.

Інвестиції в блокчейн VC використовують досвід і належну ретельність самого фонду, надаючи більшої довіри базовому проекту. Відповідно, компанії звертаються до цих більш традиційних точок фінансування, щоб зміцнити репутацію проектів та підняти свої повноваження.

Підприємницький блокчейн вступає у програму збору коштів

Оскільки інтерес бізнесу до блокчейну продовжує пришвидшуватися, децентралізований блокчейн уклав приватний продаж токена на суму 10 мільйонів євро, який призначений допомогти блокчейну розширити свій слід на корпоративній арені.

Concordium , який нещодавно оголосив про партнерство з Geely Group , планує залучити кошти, щоб допомогти великим компаніям прийняти блокчейн у багатьох областях після ретельного тестування своєї концепції ідентифікації на рівні протоколу. Блокчейн, який може підтримувати смарт-контракти, самостійні ідентифікатори тощо, планує запустити свою основну мережу протягом другого кварталу.

Менші суми не означають менш значущі проекти

Навіть коли суми є більш обмеженими порівняно з попередніми накопиченнями коштів для криптовалют, операції, що отримують приватне фінансування, аж ніяк не незначні і за своєю природою відображають зміну інфраструктури всієї екосистеми.

Протокол ставки Ethereum Stakewise є однією з організацій, які нещодавно закрили приватний раунд фінансування. Протокол розміщення статей ETH2 знаходиться на завершенні запуску основної мережі після ініціативи Early Adopters Campaign та залучення скромного приватного раунду фінансування вартістю всього 2 мільйони доларів. Останнє фінансування після початкового капіталу від Collider Labs очолював Greenfield One разом з Collider Ventures , Gumi Cryptos , Lionschain Capital та іншими приватними інвесторами.

Ще одним джерелом зростання проекту є Automata Network , провідний протокол проміжного програмного забезпечення, орієнтований на конфіденційність. Спільне залучення коштів у розмірі 1 мільйон доларів надійшло від консорціуму, який включає Alameda Research , Divergence Capital , Genesis Block Ventures , IOSG Ventures та KR1 .

Компанія, яка зосереджується на додаванні додаткових функціональних можливостей та інфраструктури Web3 до існуючих проектів, має намір використати ці кошти для подальших досліджень та розробок продуктів, а також розширити сферу діяльності Automata Network та залучення громади.

Переклад статті Private Blockchain Project Funding Accelerates as Companies Race to Address New Needs

Семантика врятує інтернет

Років п’ятнадцять тому майже всі робили сайти і не переживали про те, що під капотом. Верстали таблицями, використовували все, що попадеться під руку і не особливо морочилися про доступність. А потім стався HTML5 і понеслося.

Семантична верстка – підхід до розмітки, який спирається не на зовнішній вигляд сайту, а на смислове призначення кожного блоку і логічну структуру документа. Навіть в цій статті є заголовки різних рівнів – це допомагає читачеві вибудувати в голові структуру документа. Так і на сторінці сайту – тільки читачі будуть трохи іншими.

Чому семантика важлива

Щоб зробити сайт доступним. Зрячі користувачі можуть без проблем з першого погляду зрозуміти, де яка частина сторінки знаходиться – де заголовок, списки або зображення. Для незрячих або частково незрячих все складніше. Основний інструмент для перегляду сайтів не браузер, який відмальовуе сторінку, а скрінрідер, який читає текст зі сторінки вголос.

Цей інструмент «зачитує» вміст сторінки, і семантична структура допомагає йому краще визначати, який зараз блок, а користувачеві розуміти, про що йде мова. Таким чином семантична розмітка допомагає більшій кількості користувачів взаємодіяти з вашим сайтом. Наприклад, наявність заголовків допомагає незрячим в навігації по сторінці. У скрінрідеров є функція навігації по заголовкам, що прискорює знайомство з інформацією на сайті.

Щоб сайт був вище в пошукових системах. Компанії, які створюють пошуковики, не розголошують правила ранжирування, але відомо, що наявність семантичної розмітки сторінок допомагає пошуковим роботам краще розуміти, що знаходиться на сторінці, і в залежності від цього ранжувати сайти в пошуковій видачі.

Семантика прописана в стандартах. Багато розробників по-старому користуються конструкціями типу <div id="nav"> для позначення навігації або інших структурних елементів сторінки. Тим часом в стандарті HTML є кілька семантичних тегів, які рекомендується використовувати для розмітки сторінок замість <div> і <span>У специфікації для кожного семантичного елемента описана його роль.

Ну і уявіть, наскільки простіше читати <nav></nav> замість <div class="nav"></div>. Або ось такий код. Дивіться і відразу зрозуміло, що тут і навіщо.

<!DOCTYPE html>
<html lang="ua">
  <head>
    <meta charset="utf-8">
    <title>Title</title>
  </head>
  <body>
    <header class="main-header">
      <!— Header —>
    </header>
    <main>
      <!— Content —>
    </main>
    <footer class="main-footer">
      <!— Footer —>
    </footer>
  </body>
</html>

Основні семантичні теги HTML

Серед «старих» тегів з молотших версій HTML теж є семантичні – наприклад, тег <p>, який позначає параграф. При цьому теги <i> або <b> НЕ семантичні, тому що вони не додають сенсу виділеного тексту, а просто визначають його зовнішній вигляд.

Але в актуальній версії стандарту HTML Living Standard є семантичні теги майже для всіх основних частин сайту, і краще користуватися ними. Ось кілька прикладів семантичних тегів.

<Article>

  • Значення: незалежна, відокремлена смислова одиниця, наприклад коментар, твіт, стаття, віджет і так далі.
  • Особливості: бажаний заголовок всередині.
  • Типові помилки: плутають з тегами <section> і <div>.

<Section>

  • Значення: смисловий розділ документа. Невідокремлюваний, на відміну від <article>.
  • Особливості: бажаний заголовок всередині.
  • Типові помилки: плутають з тегами <article> і <div>.

<Aside>

  • Значення: побічний, непрямий для сторінки контент.
  • Особливості: може мати свій заголовок. Може зустрічатися кілька разів на сторінці.
  • Типові помилки: вважати <aside>тегом для «бічній панелі» і розмічати цим тегом основний контент, який пов’язаний з оточуючими його елементами.

<Nav>

  • Значення: навігаційний розділ з посиланнями на інші сторінки або інші частини сторінок.
  • Особливості: використовується для основної навігації, а не для всіх груп посилань. Основна навігація чи ні – на розсуд верстальника. Наприклад, меню в підвалі сайту годі й обертати в <nav>. У підвалі зазвичай з’являється короткий список посилань (наприклад, посилання на головну, копірайт і умови) – це не є основною навігацією, семантично для такої інформації призначений <footer> сам по собі.
  • Типові помилки: багато хто вважає, що в <nav> може бути тільки список навігаційних посилань, але згідно специфікації там може бути навігація в будь-якій формі.

<Header>

  • Значення: вступна частина смислового розділу або всього сайту, зазвичай містить підказки та навігацію. Найчастіше повторюється на всіх сторінках сайту.
  • Особливості: цих елементів може бути кілька на сторінці.
  • Типові помилки: використовувати тільки як шапку сайту.

<Main>

  • Значення: основне, що не повторюється на інших сторінках, зміст сторінки.
  • Особливості: має бути один на сторінці, виходячи з визначення.
  • Типові помилки: включати в цей тег то, що повторюється на інших сторінках (навігацію, авторські права і так далі).

<Footer>

  • Значення: заключна частина смислового розділу або всього сайту, зазвичай містить інформацію про авторів, список літератури, копірайт і так далі. Найчастіше повторюється на всіх сторінках сайту.
  • Особливості: цих елементів може бути кілька на сторінці. Тег <footer> не зобов’язаний перебувати в кінці розділу.
  • Типові помилки: використовувати тільки як підвал сайту.

Як розмітити сторінку з точки зору семантики

Процес розмітки можна розділити на кілька кроків з різним ступенем деталізації.

  1. Великі смислові блоки на кожній сторінці сайту. Теги: <header>, <main>, <footer>.
  2. Великі смислові розділи в блоках. Теги: <nav>, <section>, <article>, <aside>.
  3. Тема всього документа і заголовки смислових розділів. Теги: <h1>-<h6>.
  4. Дрібні елементи в смислових розділах. Списки, таблиці, демо-матеріали, параграфи і переноси, форми, цитати, контактна інформація і прогрес.
  5. Фразові елементи. Зображення, посилання, кнопки, відео, час і дрібні текстові елементи.

Сумніваюся, які теги використовувати

Є прості правила для вибору потрібних тегів.

  • Знайти найбільш підходящий смисловий тег – використовувати його.
  • Для потокових контейнерів – <div>.
  • Для дрібних фразових елементів (слово або фраза) – <span>.

Правило для визначення <article>, <section> і <div>:

  1. Можете дати ім’я розділу і винести це розділ на інший сайт? – <article>
  2. Можете дати ім’я розділу, але винести на інший сайт не можете? – <section>
  3. Чи не можете дати ім’я? Виходить щось на зразок «новини та фотогалерея» або «права колонка»? – <div>

Як точно не потрібно робити

Не використовуйте семантичні теги для прикрас. Для цього є CSS.

Може здатися, що деякі теги підходять для того, щоб зробити сторінку красивіше, посувати текст або додати йому інтервалів. Але те, що браузер за замовчуванням відображає теги якось, як вам потрібно, не означає, що це потрібно використовувати.

  1. Тег <blockquote> повинен використовуватися для виділення в тексті цитат, а не просто випадкового виділення тексту. Так співпало, що в браузерах цей блок за замовчуванням виділено, але це не означає, що потрібно його використовувати таким чином.
  2. Тег <ul> повинен бути використаний тільки для позначення списків, в тег <ul> можна вкладати тільки теги <li> і нічого більше.
  3. Тег <p> використаний, щоб візуально розсунути текст. Насправді цей тег використовується для виділення параграфів.

А будь-яке виділення, зрушення чи інші перетворення тексту можна виконати за допомогою CSS.

Тому використовуйте семантичні теги за призначенням.

Переклад статті Забудьте про div, семантика спасёт интернет

Арбитраж трафика – первая прибыль!

Потративши полторы сотни долларов на трафик в баннерных и тизерных сетях я решил немного изменить тактику. Я открыл для себя пуш сети. Порог входа в них гораздо выше, мне не совсем понятно почему так. Если в тизерную сеть минимальная оплата от 5$, то в пуш сетях все начинается от 50$ как например у  Push.house, есть и такие в которых минимальное пополнение 100$ такие как Propellerads и Evadav. Я заметил то что чем больше на площадке трафика, тем больше минимальное пополнение. Хотя стоимость кликов приблизительно такая же. Ну и модерация объявлений посильнее.  К слову когда на арбитражных площадках начали накапливаться (пусть пока и небольшие) деньги то стал вопрос как их оттуда выводить с наименьшими потерями. Остановился я на  Capitalist можно карточку заказать у них для получения налика (но пока вопрос об этом не стоит), а самое главное это из него можно пополнять пуш площадки оборотными средствами. Для покупки нового трафика чтобы можно было нарастить объемы.

Мне Push.house понравился тем что там есть база с текстами и картинками для объявлений, но как по мне то в Evadav трафик качественнее. Но это все зависит от страны и тематики.

По поводу тематики, я выбрал 2 направления: нутра и дэйтинг. Это, на мой взгляд более перспективные направления для получения. Офферы для нутры я брал на Leadrock а дэйтинг на Advrtise.

На Leadrock очень хорошие выплаты, но очень жаль что нет дэйтинга.

Пока по прибыли у меня получается не много. Небольшой +, нужно подбирать офферы и создавать новые креативы чтобы найти хорошую связку на которой можно нормально заработать.
Надеюсь что в будущем это получится делать чаще и эффективнее. А пока арбитраж трафика для меня – это как хобби и поле для экспериментов. Надеюсь что в дальнейшем это превратится в постоянный источник дохода 🙂 Но для этого нужно много учится и е только на своих ошибках.

Всем удачи всем в поиске профитных связок.
В дальнейшем буду еще писать о своих экспериментах в области арбитража трафика. Надеюсь мой опыт будет кому то полезен.

Керівництво по роботі з Redux

Redux

Сьогодні Redux – це одне з найцікавіших явищ світу JavaScript. Він виділяється із сотні бібліотек і фреймворків тим, що грамотно вирішує безліч різних питань шляхом введення простий і передбачуваною моделі станів, спрямщвані на функціональне програмування і незмінні дані, надання компактного API. Що ще потрібно для щастя? Redux – бібліотека дуже маленька, і вивчити її API не складно. Але у багатьох людей відбувається своєрідний розрив шаблону – невелика кількість компонентів і добровільні обмеження чистих функцій і незмінних даних можуть здатися невиправданим примусом. Яким саме чином працювати в таких умовах?

У цьому керівництві ми розглянемо створення з нуля full-stack додатки з використанням Redux і Immutable-js. Застосувавши підхід TDD, пройдемо всі етапи конструювання Node + Redux бекенд і React + Redux фронтенд додатку. Крім цього ми будемо використовувати такі інструменти, як ES6, BabelSocket.ioWebpack і Mocha.

1. Що вам знадобиться

Даний посібник буде найбільш корисним для розробників, які вже вміють писати JavaScript-додатки. Як уже згадувалося, ми будемо використовувати Node, ES6, React , Webpack і Babel , і якщо ви хоча б трохи знайомі з цими інструментами, ніяких проблем з просуванням не буде.

В якості гарної допомоги по розробці веб-додатків за допомогою React, Webpack і ES6, можна порадити SurviveJS . Що стосується інструментів, то вам знадобиться Node з NPM і ваш улюблений текстовий редактор.

2. Додаток

Ми будемо робити додаток для «живих» голосувань на вечірках, конференціях, зустрічах та інших зборах. Ідея полягає в тому, що користувачеві буде пропонуватися колекція позицій для голосування: фільми, пісні, мови програмування, цитати з Horse JS , і так далі. Додаток буде мати у своєму розпорядженні пари елементів, щоб кожен міг проголосувати за свого фаворита. В результаті серії голосувань залишиться один елемент – переможець. Приклад голосування за кращий фільм Денні Бойла:

Redux

Додаток буде мати два різних призначених для користувача інтерфейсу:

  • Інтерфейс для голосування можна буде використовувати на будь-якому пристрої, де запускається веб-браузер.
  • Інтерфейс результатів голосування може бути виведений на проектор або якийсь великий екран. Результати голосування будуть оновлюватися в реальному часі.

app

3. Архітектура

Структурно система буде складатися з двох додатків:

  • Браузерний додаток на React, що надає обидва призначених для користувача інтерфейси.
  • Серверний додаток на Node, що містить логіку голосування.

Взаємодія між додатками буде здійснюватися за допомогою WebSockets. Redux допоможе нам організувати код клієнтської і серверної частин. А для зберігання станів будемо застосовувати структури Immutable .

Незважаючи на велику схожість клієнта і сервера – наприклад, обидва будуть використовувати Redux, – це не універсальний / изоморфний додаток , і додатки не будуть спільно використовувати будь-якої код. Швидше це можна охарактеризувати як розподілену систему з двох додатків, що взаємодіють один з одним за допомогою передачі повідомлень.

4. Серверний додаток

Спочатку напишемо Node-додаток, а потім – React. Це дозволить нам не відволікатися від реалізації базової логіки додатка, перш ніж ми перейдемо до інтерфейсу. Оскільки ми створюємо серверний додаток, будемо знайомитися з Redux і Immutable і дізнаємося, як буде влаштовано побудований на них додаток. Зазвичай Redux асоціюється з React-проектами, але його застосування зовсім ними не обмежується. Зокрема, ми дізнаємося, наскільки Redux може бути корисний і в інших контекстах!

4.1. Розробка дерева станів додатку

Створення програми за допомогою Redux часто починається з продумування структури даних стану програми (application state) . З її допомогою описується, що відбувається в додатку в кожен момент часу. Стан (state) є у будь-якого фреймворка і архітектури. У додатках на базі Ember і Backbone стан зберігається в моделях (Models). У додатках на базі Angular стан найчастіше зберігається в фабриках (Factories) і сервісах (Services). У більшості Flux-додатків стан є сховищем (Stores). А як це зроблено в Redux?

Головна його відмінність в тому, що їхній стан додатку зберігаються в єдиній структурі дерева. Таким чином все, що необхідно знати про стан додатку, міститься в одній структурі даних з асоціативних (map) та звичайних масивів. Як ви незабаром побачите, у цього рішення є чимало наслідків. Одним з найважливіших є те, що ви можете відокремити стан додатку від його поведінки . Стан – це чисті дані. Він не містить ніяких методів або функцій, і він не схований у середину інших об’єктів. Все знаходиться в одному місці. Це може здатися обмеженням, особливо якщо у вас є досвід об’єктно-орієнтованого програмування. Але насправді це прояв більшої свободи, оскільки ви можете сконцентруватися на одних лише даних. Дуже багато логічного витече з проектування станів додатки якщо ви приділите цьому достатньо часу.

Я не хочу сказати, що вам завжди потрібно спочатку повністю розробляти дерево станів, а потім створювати інші компоненти програми. Зазвичай це роблять паралельно. Але мені здається, що корисніше спочатку в загальних рисах уявити собі, як має виглядати дерево в різних ситуаціях, перш ніж приступати до написання коду. Давайте уявимо, яким може бути дерево станів для нашого застосування голосувань. Мета програми – мати можливість голосувати всередині пар об’єктів (фільми, музичні групи). В якості початкового стану програми доцільно зробити просто колекцію з позицій, які братимуть участь в голосуванні. Назвемо цю колекцію entries (записи) :

entries

Після початку голосування потрібно якось відокремити позиції, які беруть участь в голосуванні в даний момент. У стані може бути сутність vote , що містить пару позицій, з яких користувач повинен вибрати одну. Природно, ця пара повинна бути залучена з колекції entries:

Entries

Також нам потрібно вести облік результатів голосування. Це можна робити за допомогою іншої структури всередині vote:

Entries

По завершенні поточного голосування запис що програв викидається, а який переміг повертається назад в entries і поміщається в кінець списку. Пізніше він знову братиме участь у голосуванні. Потім зі списку береться наступна пара:

entries

Ці стани циклічно змінюють один одного до тих пір, поки в колекції є записи. В кінці залишиться тільки один запис, який оголошується переможцем, а голосування завершується:

Entries

Схема здається цілком розумною, почнемо її реалізовувати. Є багато різних способів розробки станів під ці вимоги, можливо, цей варіант і не оптимальний. Але це не дуже важливо. Початкова схема повинна бути просто хорошою для старту. Головне, що у нас є розуміння того, як має працювати наш додаток. І це ще до того, як ми перейшли до написання коду!

4.2. Налаштування проекту

Для початку потрібно створити папку проекту, а потім ініціалізувати його в якості NPM-проекту:

mkdir voting-server
cd voting-server
npm init -y

У створеній папці поки що лежить самотній файл package.json. Писати код ми будемо в специфікації ES6. Хоча Node починаючи з версії 4.0.0 підтримує багато можливостей ES6, необхідні нам модулі все ж залишилися за бортом. Тому нам потрібно додати в наш проект Babel, щоб ми могли скористатися всією потужністю ES6 і транспіліровать код в ES5:

npm install --save-dev babel-core babel-cli babel-preset-es2015

Також нам знадобляться бібліотеки для написання unit тестів:

npm install --save-dev mocha chai

Фреймворк для тестування будемо використовувати Mocha . Усередині тестів будемо використовувати Chai в ролі бібліотеки для перевірки очікуваного поведінки і станів. Запускати тести ми будемо за допомогою команди mocha:

./node_modules/mocha/bin/mocha --compilers js:babel-core/register --recursive

Після цього Mocha буде рекурсивно шукати всі тести проекту і запускати їх. Для транспілінга ES6-коду перед його запуском буде використовуватися Babel. Для зручності можна зберігати цю команду в package.json:

package.json
"scripts": {
  "test": "mocha --compilers js:babel-core/register --recursive"
},

Тепер нам потрібно включити в Babel підтримку ES6 / ES2015. Для цього активуємо вже встановлений нами пакет babel-preset-es2015. Далі просто додамо в package.jsonсекцію "babel":

package.json
"babel": {
  "presets": ["es2015"]
}

Тепер за допомогою команди npmми можемо запускати наші тести:

npm run test

Команда test:watchможе використовуватися для запуску процесу, що відслідковує зміни в нашому коді і запускає тести після кожної зміни:

package.json
"scripts": {
  "test": "mocha --compilers js:babel-core/register --recursive",
  "test:watch": "npm run test -- --watch"
},

Розроблена в Facebook бібліотека Immutable надає нам ряд корисних структур даних. Ми обговоримо її в наступному розділі, а поки просто додамо в проект поряд з бібліотекою chai-immutable , яка додає в Chai підтримку порівняння Immutable-структур:

npm install --save immutable
npm install --save-dev chai-immutable

Підключати chai-immutable потрібно до запуску будь-яких тестів. Зробити це можна за допомогою файлу test_helper:

test/test_helper.js
import chai from 'chai';
import chaiImmutable from 'chai-immutable';
chai.use(chaiImmutable);

Тепер зробимо так, щоб Mocha завантажувала цей файл до запуску тестів:

package.json
"scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js  --recursive",
  "test:watch": "npm run test -- --watch"
},

Тепер у нас є все, щоб почати.

4.3. Знайомство з незмінними даними

Другий важливий момент, пов’язаний з архітектурою Redux: стан – це не просто дерево, а незмінне дерево (immutable tree) . Структура дерев з попередньої глави може навести на думку, що код повинен міняти стан додатки просто оновлюючи дерева: замінюючи елементи в асоціативних масивах, видаляючи їх з масивів і т.д. Але в Redux все робиться по-іншому. Дерево станів в Redux-додатку є незмінну структуру даних (immutable data structure) . Це означає, що поки дерево існує, воно не змінюється. Воно завжди зберігає один і той же стан. І перехід до іншого стану здійснюється за допомогою створення іншого дерева, в яке внесені необхідні зміни. Тобто два наступних один за одним states програми зберігаються в двох окремих і незалежних деревах. А перемикання між деревами здійснюється за допомогою виклику функції , що приймає поточний стан і повертає наступне.

tree

Чи хороша це ідея? Зазвичай відразу вказують на те, що якщо їхні states зберігаються в одному дереві і ви вносите всі ці безпечні поновлення, то можна без особливих зусиль зберігати історію states додатку. Це дозволяє реалізувати undo / redo “безкоштовно” – можна просто поставити попереднє або наступне state (дерево) з історії. Також можна серіалізовать історію і зберегти її на майбутнє, або помістити її в сховище для подальшого програвання, що може надати неоціненну допомогу в налагодженні.

Але мені здається, що, крім усіх цих додаткових можливостей, головна перевага використання незмінних даних полягає в спрощенні коду. Вам доводиться програмувати чисті функції: Вони тільки приймають і повертають дані, і більше нічого. Ці функції поводяться передбачувано. Ви можете викликати їх скільки завгодно разів, і вони завжди будуть вести себе однаково. Давайте їм одні і ті ж аргументи, і будете отримувати одні і ті ж результати. Тестування стає тривіальним, адже вам не потрібно налаштовувати заглушки або інші фальшивки, щоб «підготувати всесвіт» до виклику функції. Є просто вхідні і вихідні дані.

Оскільки ми будемо описувати стан нашого застосування незмінними структурами, давайте витратимо трохи часу на знайомство з ними, написавши кілька unit-тестів, що ілюструють роботу.

Якщо ж ви впевнено працюєте з незмінними даними і бібліотекою Immutable , то можете приступити до наступного розділу.

Для ознайомлення з ідеєю незмінності можна для початку поговорити про найпростішої структури даних. Припустимо, у вас є додаток-лічильник, стан якого є число. Скажімо, воно змінюється від 0 до 1, потім до 2, потім до 3 і т.д. В принципі, ми вже думаємо про числах як про незмінних даних. Коли лічильник збільшується, то число не змінюється . Та це й неможливо, адже у чисел немає «сеттерів». Ви не можете сказати 42.setValue(43).

Так що ми просто отримуємо інше число, додаючи до попереднього одиницю. Це можна зробити за допомогою чистої функції. Її аргументом буде поточний стан, а повертається значення буде використовуватися в якості наступного стану. Викликається, не змінює поточний стан. Ось її приклад, а також unit тест до неї:

test/immutable_spec.js
import {expect} from 'chai';

describe('immutability', () => {

  describe('a number', () => {

    function increment(currentState) {
      return currentState + 1;
    }

    it('is immutable', () => {
      let state = 42;
      let nextState = increment(state);

      expect(nextState).to.equal(43);
      expect(state).to.equal(42);
    });

  });

});

Очевидно, що stateне змінюється при виклику increment, адже числа незмінні!

Як ви могли помітити, цей тест нічого не робить з нашим додатком, ми його поки і не писали зовсім.

Тести можуть бути просто інструментом навчання для нас. Я часто знаходжу корисним вивчати нові API або методики за допомогою написання модульних тестів, проганяю якісь ідеї. У книзі Test-Driven Development подібні тести отримали назву «навчальних тестів».

Тепер поширимо ідею незмінності на всі види структур даних, а не тільки на числа.

За допомогою Immutable списків ми можемо, наприклад, зробити додаток, чиїм станом буде список фільмів. Операція додавання нового фільму створить новий список, який являє собою комбінацію старого списку і додається позиції . Важливо відзначити, що після цієї операції старий стан залишається незміненим:

test/immutable_spec.js
import {expect} from 'chai';
import {List} from 'immutable';

describe('immutability', () => {

  // ...

  describe('A List', () => {

    function addMovie(currentState, movie) {
      return currentState.push(movie);
    }

    it('is immutable', () => {
      let state = List.of('Trainspotting', '28 Days Later');
      let nextState = addMovie(state, 'Sunshine');

      expect(nextState).to.equal(List.of(
        'Trainspotting',
        '28 Days Later',
        'Sunshine'
      ));
      expect(state).to.equal(List.of(
        'Trainspotting',
        '28 Days Later'
      ));
    });

  });

});

А якби ми вставили фільм в звичайний масив, то старий стан змінився б. Але замість цього ми використовуємо списки з Immutable, тому застосовуємо ту ж семантику, що і в попередньому прикладі з числами.

Ця ідея також добре застосовна і до повноцінних деревах станів. Дерево є вкладеною структурою списків (lists), асоціативних масивів ( maps ) і інших типів колекцій. Застосовувана до нього операції створює нове дерево стану , залишаючи попереднє в недоторканності. Якщо дерево являє собою асоціативний масив з ключемmovies, Що містить список фільмів, то додавання нової позиції має на увазі необхідність створення нового масиву, в якому ключ moviesвказує на новий список:

test/immutable_spec.js
import {expect} from 'chai';
import {List, Map} from 'immutable';

describe('immutability', () => {

  // ...

  describe('a tree', () => {

    function addMovie(currentState, movie) {
      return currentState.set(
        'movies',
        currentState.get('movies').push(movie)
      );
    }

    it('is immutable', () => {
      let state = Map({
        movies: List.of('Trainspotting', '28 Days Later')
      });
      let nextState = addMovie(state, 'Sunshine');

      expect(nextState).to.equal(Map({
        movies: List.of(
          'Trainspotting',
          '28 Days Later',
          'Sunshine'
        )
      }));
      expect(state).to.equal(Map({
        movies: List.of(
          'Trainspotting',
          '28 Days Later'
        )
      }));
    });

  });

});

Тут ми бачимо точно таку ж поведінку, як і раніше, розширене для демонстрації роботи з вкладеними структурами. Ідея незмінності застосовна до даних всіх форм і розмірів.

Для операцій над подібними вкладеними структурами в Immutable є кілька допоміжних функцій, що полегшують «залізання» у вкладені даних заради отримання оновленого значення. Для стислості коду можемо використовувати функцію update :

test/immutable_spec.js
function addMovie(currentState, movie) {
  return currentState.update('movies', movies => movies.push(movie));
}

Схожу функцію ми будемо використовувати в нашому додатку для поновлення стану програми. В API Immutable ховається чимало інших можливостей, і ми лише розглянули верхівку айсберга.

Незмінні дані є ключовим аспектом архітектури Redux, але не існує жорсткої вимоги використовувати саме бібліотеку Immutable. В офіційній документації Redux по більше частини згадуються прості об’єкти і масиви JavaScript, і від їх зміни утримуються за угодою.

Існує ряд причин, за якими в нашому ж керівництві буде використана бібліотека Immutable:

  • Структури даних в Immutable розроблені з нуля, щоб бути незмінними і надають API, який дозволяє зручно виконувати операції над ними.
  • Я поділяю точки зору Річа Хайки, згідно з якою не існує такої речі, як незмінність за угодою . Якщо ви використовуєте структури даних, які можуть бути змінені, то рано чи пізно хтось помилиться і зробить це. Особливо якщо ви новачок. Речі начебто Object.freeze () допоможуть вам не помилитися.
  • Незмінні структури даних є персистентного , тобто їх внутрішня структура така, що створення нової версії є ефективною операцією з точки зору часу і споживання пам’яті, особливо в разі великих дерев станів. Використання звичайних об’єктів і масивів може привести до надмірного копіювання, що знижує продуктивність.

4.4. Реалізація логіки додатка за допомогою чистих функцій

Познайомившись з ідеєю незмінних дерев станів і функціями, які оперують цими деревами, можна перейти до створення логіки нашого застосування. В її основу ляжуть розглянуті вище компоненти: деревоподібна структура і набір функцій, що створюють нові версії цього дерева.

4.4.1. Завантаження записів

В першу чергу, застосування повинне «завантажувати» колекцію записів для голосування. Можна зробити функцію setEntries, що бере попередній стан і колекцію, і створює новий стан, включивши туди записи. Ось тест для цієї функції:

test/core_spec.js
import {List, Map} from 'immutable';
import {expect} from 'chai';

import {setEntries} from '../src/core';

describe('application logic', () => {

  describe('setEntries', () => {

    it('добавляет записи к состоянию', () => {
      const state = Map();
      const entries = List.of('Trainspotting', '28 Days Later');
      const nextState = setEntries(state, entries);
      expect(nextState).to.equal(Map({
        entries: List.of('Trainspotting', '28 Days Later')
      }));
    });

  });

});

Первісна реалізація setEntries робить тільки найпростіше: ключу entries в асоціативному масиві стану привласнює в якості значення вказаний список записів. Отримуємо перше з спроектованих нами раніше дерев.

src/core.js
export function setEntries(state, entries) {
  return state.set('entries', entries);
}

Для зручності дозволимо вхідним записам являти собою звичайний JavaScript-масив (або що-небудь ітеріруеме ). У дереві стану же повинен бути присутнім Immutable список ( List):

test/core_spec.js
it('преобразует в immutable', () => {
  const state = Map();
  const entries = ['Trainspotting', '28 Days Later'];
  const nextState = setEntries(state, entries);
  expect(nextState).to.equal(Map({
    entries: List.of('Trainspotting', '28 Days Later')
  }));
});

Для задоволення цієї вимоги будемо передавати записи в конструктор списку:

src/core.js
import {List} from 'immutable';

export function setEntries(state, entries) {
  return state.set('entries', List(entries));
}

4.4.2. запуск голосування

Голосування можна запустити викликом функції next при стані, вже має набір записів. Таким чином буде здійснено перехід від першого до другого з спроектованих дерев.

Цій функції не потрібні додаткові аргументи. Вона повинна створювати асоціативний масив vote, в якому по ключу pair лежать два перші записи. При цьому записи, які в даний момент беруть участь в голосуванні, більше не повинні перебувати в списку entries:

test/core_spec.js
import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries, next} from '../src/core';

describe('логика приложения', () => {

  // ..

  describe('далее', () => {

    it('берёт для голосования следующие две записи', () => {
      const state = Map({
        entries: List.of('Trainspotting', '28 Days Later', 'Sunshine')
      });
      const nextState = next(state);
      expect(nextState).to.equal(Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later')
        }),
        entries: List.of('Sunshine')
      }));
    });

  });

});

Реалізація функції буде об’єднувати (merge) оновлення зі старим станом, обособляя перші записи лежать в окремий список, а інші – в нову версію списку entries:

src/core.js
import {List, Map} from 'immutable';

// ...

export function next(state) {
  const entries = state.get('entries');
  return state.merge({
    vote: Map({pair: entries.take(2)}),
    entries: entries.skip(2)
  });
}

4.4.3. голосування

По мірі продовження голосування, користувач повинен мати можливість віддавати голос за різні записи. І при кожному новому голосуванні на екрані повинен відображатися поточний результат. Якщо за певний запис вже голосували, то її лічильник повинен збільшитися.

test/core_spec.js
import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries, next, vote} from '../src/core';

describe('логика приложения', () => {

  // ...

  describe('vote', () => {

    it('создаёт результат голосования для выбранной записи', () => {
      const state = Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later')
        }),
        entries: List()
      });
      const nextState = vote(state, 'Trainspotting');
      expect(nextState).to.equal(Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({
            'Trainspotting': 1
          })
        }),
        entries: List()
      }));
    });

    it('добавляет в уже имеющийся результат для выбранной записи', () => {
      const state = Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({
            'Trainspotting': 3,
            '28 Days Later': 2
          })
        }),
        entries: List()
      });
      const nextState = vote(state, 'Trainspotting');
      expect(nextState).to.equal(Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({
            'Trainspotting': 4,
            '28 Days Later': 2
          })
        }),
        entries: List()
      }));
    });

  });

});

За допомогою функції fromJS з Immutable можна більш лаконічно створити всі ці вкладені схеми і списки.

Проженемо тести:

src/core.js
export function vote(state, entry) {
  return state.updateIn(
    ['vote', 'tally', entry],
    0,
    tally => tally + 1
  );
}

Використання updateIn дозволяє не розтікатися мислію по древу. У цьому коді говориться: «візьми шлях вкладеної структури даних [ 'vote''tally''Trainspotting'] і застосуй цю функцію. Якщо якісь ключі відсутні, то створи замість них нові масиви ( Map). Якщо в кінці не вказане значення, то не започатковано нулем ». Саме такого роду код дозволяє отримувати задоволення від роботи з незмінними структурами даних, так що варто приділити цьому час і попрактикуватися.

4.4.4. Перехід до наступної пари

Після закінчення голосування по поточній парі, переходимо до наступної. Потрібно зберегти переможця і додати в кінець списку записів, щоб пізніше він знову взяв участь в голосуванні. Програвший запис просто викидається. У разі нічиєї зберігаються обидві записи.

Додамо цю логіку до наявної реалізації next:

test/core_spec.js
describe('next', () => {

  // ...

  it('помещает победителя текущего голосования в конец списка записей', () => {
    const state = Map({
      vote: Map({
        pair: List.of('Trainspotting', '28 Days Later'),
        tally: Map({
          'Trainspotting': 4,
          '28 Days Later': 2
        })
      }),
      entries: List.of('Sunshine', 'Millions', '127 Hours')
    });
    const nextState = next(state);
    expect(nextState).to.equal(Map({
      vote: Map({
        pair: List.of('Sunshine', 'Millions')
      }),
      entries: List.of('127 Hours', 'Trainspotting')
    }));
  });

  it('в случае ничьей помещает обе записи в конец списка', () => {
    const state = Map({
      vote: Map({
        pair: List.of('Trainspotting', '28 Days Later'),
        tally: Map({
          'Trainspotting': 3,
          '28 Days Later': 3
        })
      }),
      entries: List.of('Sunshine', 'Millions', '127 Hours')
    });
    const nextState = next(state);
    expect(nextState).to.equal(Map({
      vote: Map({
        pair: List.of('Sunshine', 'Millions')
      }),
      entries: List.of('127 Hours', 'Trainspotting', '28 Days Later')
    }));
  });

});

В нашій реалізації ми просто з’єднуємо переможців поточного голосування з записами. А знаходити цих переможців можна за допомогою нової функції getWinners:

src/core.js
function getWinners(vote) {
  if (!vote) return [];
  const [a, b] = vote.get('pair');
  const aVotes = vote.getIn(['tally', a], 0);
  const bVotes = vote.getIn(['tally', b], 0);
  if      (aVotes > bVotes)  return [a];
  else if (aVotes < bVotes)  return [b];
  else                       return [a, b];
}

export function next(state) {
  const entries = state.get('entries')
                       .concat(getWinners(state.get('vote')));
  return state.merge({
    vote: Map({pair: entries.take(2)}),
    entries: entries.skip(2)
  });
}

4.4.5. завершення голосування

У якийсь момент у нас залишається лише один запис – переможець, і тоді голосування завершується. І замість формування нового голосування, ми явно призначаємо цей запис переможцем в поточному стані. Кінець голосування.

test/core_spec.js
describe('next', () => {

  // ...

  it('когда остаётся лишь одна запись, помечает её как победителя', () => {
    const state = Map({
      vote: Map({
        pair: List.of('Trainspotting', '28 Days Later'),
        tally: Map({
          'Trainspotting': 4,
          '28 Days Later': 2
        })
      }),
      entries: List()
    });
    const nextState = next(state);
    expect(nextState).to.equal(Map({
      winner: 'Trainspotting'
    }));
  });

});

У реалізації next потрібно передбачити обробку ситуації, коли після завершення чергового голосування в списку записів залишається лише одна позиція:

src/core.js
export function next(state) {
  const entries = state.get('entries')
                       .concat(getWinners(state.get('vote')));
  if (entries.size === 1) {
    return state.remove('vote')
                .remove('entries')
                .set('winner', entries.first());
  } else {
    return state.merge({
      vote: Map({pair: entries.take(2)}),
      entries: entries.skip(2)
    });
  }
}

Тут можна було б просто повернути Map({winner: entries.first()}). Але замість цього ми знову беремо старий стан і явно прибираємо з нього ключі vote і entries. Це робиться з прицілом на майбутнє: може статися так, що в нашому стані з’являться якісь сторонні дані, які потрібно буде в незмінному вигляді передати за допомогою цієї функції. В цілому, в основі функцій трансформування станів лежить гарна ідея – завжди перетворювати старе стан в нове, замість створення нового стану з нуля.

Тепер у нас є цілком прийнятна версія основний логіки нашого застосування, виражена у вигляді декількох функцій. Також ми написали для них unit тести, які далися нам досить легко: ніяких преднастроек і заглушок. В цьому і проявляється краса чистих функцій. Можна просто викликати їх і перевірити повернені значення.

Зверніть увагу, що ми поки ще навіть не встановили Redux. При цьому спокійно займалися розробкою логіки додатка, не привертаючи «фреймворк» до цього завдання. Є в цьому щось чортовски приємне.

4.5. Використання Actions і Reducers

Отже, у нас є основні функції, але ми не будемо викликати їх в Redux безпосередньо. Між функціями і зовнішнім світом розташований шар непрямої адресації: дії ( Actions).

Це прості структури даних, що описують зміни, які повинні відбутися зі станом вашого застосування. По суті це опис виклику функції, упаковане в маленький об’єкт. За угодою, кожна дія має атрибут type, що описує, для якої операції ця дія призначене. Також можуть використовуватися і додаткові атрибути. Ось кілька прикладів дій, які підходять для наших основних функцій:

{type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']}

{type: 'NEXT'}

{type: 'VOTE', entry: 'Trainspotting'}

При такому способі вираження нам ще знадобиться перетворити їх в нормальні виклики основних функцій. У випадку з VOTE повинен виконуватися наступний виклик:

// Этот action
let voteAction = {type: 'VOTE', entry: 'Trainspotting'}
// должен сделать это:
return vote(state, voteAction.entry);

Тепер потрібно написати шаблонну функцію (generic function), приймаючу будь-яку дію – в рамках поточного стану – і викликає відповідну функцію ядра. Така функція називається перетворювачем ( reducer):

src/reducer.js
export default function reducer(state, action) {
  // Определяет, какую функцию нужно вызвать, и делает это
}

Тепер потрібно переконатися, що наш reducer здатний обробляти кожне з трьох дій:

test/reducer_spec.js
import {Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {

  it('handles SET_ENTRIES', () => {
    const initialState = Map();
    const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      entries: ['Trainspotting']
    }));
  });

  it('handles NEXT', () => {
    const initialState = fromJS({
      entries: ['Trainspotting', '28 Days Later']
    });
    const action = {type: 'NEXT'};
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later']
      },
      entries: []
    }));
  });

  it('handles VOTE', () => {
    const initialState = fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later']
      },
      entries: []
    });
    const action = {type: 'VOTE', entry: 'Trainspotting'};
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      },
      entries: []
    }));
  });

});

Залежно від типу дії reducer повинен звертатися до однієї з функцій ядра. Він також повинен знати, як витягти з дії додаткові аргументи для кожної з функцій:

src/reducer.js
import {setEntries, next, vote} from './core';

export default function reducer(state, action) {
  switch (action.type) {
  case 'SET_ENTRIES':
    return setEntries(state, action.entries);
  case 'NEXT':
    return next(state);
  case 'VOTE':
    return vote(state, action.entry)
  }
  return state;
}

Зверніть увагу, що якщо reducer не розпізнає дію, то просто поверне поточний стан.

До reducer-а пред’являється важлива додаткова вимога: якщо воін викликається з незнайщмим станом, то повинен знати, як проинициализировать його правильним значенням. У нашому випадку вихідним значенням є асоціативний масив. Таким чином, стан undefined має оброблятися, як якщо б ми передали порожній масив:

test/reducer_spec.js
describe('reducer', () => {

  // ...

  it('has an initial state', () => {
    const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
    const nextState = reducer(undefined, action);
    expect(nextState).to.equal(fromJS({
      entries: ['Trainspotting']
    }));
  });

});

Оскільки логіка нашого застосування розташована в core.js, то тут же можна оголосити початковий стан:

src/core.js
export const INITIAL_STATE = Map();

Потім ми імпортуємо його в reducer-е і використовуємо в якості значення за замовчуванням для аргументу стану:

src/reducer.js
import {setEntries, next, vote, INITIAL_STATE} from './core';

export default function reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
  case 'SET_ENTRIES':
    return setEntries(state, action.entries);
  case 'NEXT':
    return next(state);
  case 'VOTE':
    return vote(state, action.entry)
  }
  return state;
}

Цікаво те, як абстрактно reducer може використовувати, щоб залишити програму з одного стану в інший за допомогою дії будь-якого типу. В принципі, взявши колекцію минулих дій, ви дійсно можете просто перетворити її в поточний стан. Саме тому функція називається перетворювач : вона замінює собою виклик callback-a.

test/reducer_spec.js
it('может использоваться с reduce', () => {
  const actions = [
    {type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']},
    {type: 'NEXT'},
    {type: 'VOTE', entry: 'Trainspotting'},
    {type: 'VOTE', entry: '28 Days Later'},
    {type: 'VOTE', entry: 'Trainspotting'},
    {type: 'NEXT'}
  ];
  const finalState = actions.reduce(reducer, Map());

  expect(finalState).to.equal(fromJS({
    winner: 'Trainspotting'
  }));
});

Здатність створювати і / або програвати колекції дій є головною перевагою моделі переходів станів за допомогою action / reducer, в порівнянні з прямим викликом функцій ядра. Оскільки actions – це об’єкти, які можна серіалізовати у JSON, то ви, наприклад, можете легко відправляти їх в Web Worker, і там вже виконувати логіку reducer-a. Або навіть можете відправляти їх по мережі, як ми це зробимо нижче.

Зверніть увагу, що в якості actions ми використовуємо прості об’єкти, а не структури даних з Immutable. Цього вимагає від нас Redux.

4.6. Присмак Reducer-композиції

Згідно з логікою нашого ядра, кожна функція приймає і повертає повний стан додатку.

Але можна легко помітити, що в великих програмах цей підхід може виявитися не кращим рішенням. Якщо кожна операція в додатку повинна знати про структуру всього стану, то ситуація швидко може стати нестабільною. Адже для зміни стану потрібно внести купу інших змін.

Найкраще в будь-яких можливих випадках виконувати операції в рамках якомога меншу частину стану (або в піддереве ). Мова йде про модульности: функціональність працює тільки з якоїсь однієї частиною даних, немов інше і не існує.

Але в нашому випадку додаток такий маленький, що у нас не виникне вищеописаних проблем. Хоча дещо поліпшити ми все ж можемо: функції vote можна не передавати увесь стан додатку, адже він працює тільки з однойменним сегментом vote. І тільки про нього йому досить знати. Для відображення цієї ідеї ми можемо модифікувати наші unit тести для vote:

test/core_spec.js
describe('vote', () => {

  it('создаёт результат голосования для выбранной записи', () => {
    const state = Map({
      pair: List.of('Trainspotting', '28 Days Later')
    });
    const nextState = vote(state, 'Trainspotting')
    expect(nextState).to.equal(Map({
      pair: List.of('Trainspotting', '28 Days Later'),
      tally: Map({
        'Trainspotting': 1
      })
    }));
  });

  it('добавляет в уже имеющийся результат для выбранной записи', () => {
    const state = Map({
      pair: List.of('Trainspotting', '28 Days Later'),
      tally: Map({
        'Trainspotting': 3,
        '28 Days Later': 2
      })
    });
    const nextState = vote(state, 'Trainspotting');
    expect(nextState).to.equal(Map({
      pair: List.of('Trainspotting', '28 Days Later'),
      tally: Map({
        'Trainspotting': 4,
        '28 Days Later': 2
      })
    }));
  });

});

Як бачите, код тесту спростився, а це зазвичай хороший знак!

Тепер реалізація vote повинна просто брати відповідний сегмент стану і оновлювати лічильник голосування:

src/core.js
export function vote(voteState, entry) {
  return voteState.updateIn(
    ['tally', entry],
    0,
    tally => tally + 1
  );
}

Далі reducer повинен взяти стан і передати функції vote тільки необхідну частину.

src/reducer.js
export default function reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
  case 'SET_ENTRIES':
    return setEntries(state, action.entries);
  case 'NEXT':
    return next(state);
  case 'VOTE':
    return state.update('vote',
                        voteState => vote(voteState, action.entry));
  }
  return state;
}

Це лише невеликий приклад підходу, важливість якого сильно зростає зі збільшенням розміру програми: головна функція-reducer просто передає окремі сегменти стану reducer-ам рівнем нижче. Ми відокремлюємо завдання пошуку потрібного сегмента дерева станів від застосування поновлення до цього сегменту.

Набагато докладніше шаблони reducer-композиції розглянуті у відповідній секції документації Redux. Також там пояснюються деякі допоміжні функції, в багатьох випадках полегшують використання reducer-композиції.

4.7. Використання Redux Store

Тепер, коли у нас є reducer, можна почати думати, як все це підключити до Redux.

Як ми тільки що бачили, якщо у вас є колекція всіх дій, які колись матимуть місце в вашому додатку, що ви можете просто викликати reduce і отримати на виході фінальний стан додатку. Звичайно, зазвичай у вас немає такої колекції. Дії здійснюються поступово, по мірі виникнення різних подій: коли користувач взаємодіє з додатком, коли дані приходять з мережі, по триггеру таймаута.

Пристосуватися до ситуації допомагає сховище – Redux Store. Як підказує логіка, це об’єкт, в якому зберігається стан нашого застосування.

Сховище инициализирується reducer-функцією, на зразок вже реалізованої нами:

import {createStore} from 'redux';

const store = createStore(reducer);

Далі можна передати (dispatch) дії в store, який потім скористається reducer-ом для застосування цих дій до поточного стану. Як результат цієї процедури ми отримаємо наступне стан, яке буде знаходитися в Redux-Store.

store.dispatch({type: 'NEXT'});

Ви можете отримати з сховища поточний стан в будь-який момент часу:

store.getState();

Давайте налаштуємо і експортуємо Redux Store в файл store.js. Але спочатку протестуємо: нам потрібно створити сховище, вважати його початковий стан, передати action і спостерігати змінени у ньому:

test/store_spec.js
import {Map, fromJS} from 'immutable';
import {expect} from 'chai';

import makeStore from '../src/store';

describe('store', () => {

  it('хранилище сконфигурировано с помощью правильного преобразователя', () => {
    const store = makeStore();
    expect(store.getState()).to.equal(Map());

    store.dispatch({
      type: 'SET_ENTRIES',
      entries: ['Trainspotting', '28 Days Later']
    });
    expect(store.getState()).to.equal(fromJS({
      entries: ['Trainspotting', '28 Days Later']
    }));
  });

});

Перед створенням Store нам потрібно додати Redux в проект:

npm install --save redux

Тепер можна створювати store.js, в якому викличемо createStore з нашим reducer-м:

src/store.js
import {createStore} from 'redux';
import reducer from './reducer';

export default function makeStore() {
  return createStore(reducer);
}

Отже, Redux Store з’єднує частини нашого застосування в ціле, яке можна використовувати як центральну точку – тут знаходиться поточний стан, сюди приходять actions, які переводять програму з одного стану в інший за допомогою логіки ядра, яка транслюється через reducer.

Питання: Скільки змінних в Redux-додатку вам потрібно?
Відповідь: Одна. Усередині сховища.

На перший погляд це звучить дивно. Принаймні, якщо у вас не так багато досвіду в функціональному програмуванні. Як можна зробити хоч щось корисне лише з однією змінною?

Але більше нам і не потрібно . Поточне дерево станів – єдина річ, яка змінюється з часом в нашому базовому додатку. Все інше – це константи і незмінні значення.

Примітно, наскільки мала площа зіткнення між кодом нашого застосування і Redux. Завдяки тому, що у нас є шаблонна reducer-функція, нам достатньо повідомити Redux лише про неї. А все інше є в нашому власному, не що залежить від фреймворка, портіруемость і виключно функціональному коді!

Якщо ми тепер створимо вхідні точку прикладення – index.js, то зможемо створити і експортувати Store:

index.js
import makeStore from './src/store';

export const store = makeStore();

А якщо вже ми його експортували, то можемо тепер завести і Node REPL (наприклад, за допомогою babel-node), запросити файл index.js і взаємодіяти з додатком за допомогою Store.

4.8. Налаштування сервера Socket.io

Наш додаток буде працювати в якості сервера для іншої браузерної програми, що має користувальницький інтерфейс для голосування і перегляду результатів. Нам потрібно організувати взаємодію клієнтів з сервером, і навпаки.

Наш додаток тільки виграє від впровадження спілкування в реальному часі, оскільки користувачам сподобається відразу ж спостерігати результати своїх дій і дій інших. Для цієї мети давайте скористаємося WebSocket’амі. Точніше, візьмемо бібліотеку Socket.io , яка надає хорошу абстракцію для працюючих в браузерах WebSocket’ів. До того ж тут є і кілька запасних механізмів  для клієнтів, які не підтримують WebSocket’и.

Додаємо Socket.io в проект:

npm install --save socket.io

Створюємо файл server.js, що експортує функцію створення сервера Socket.io:

src/server.js
import Server from 'socket.io';

export default function startServer() {
  const io = new Server().attach(8090);
}

Цей код створює сервер Socket.io, а також піднімає на порте 8090 звичайний HTTP-сервер. Порт обраний довільно, він повинен збігатися з портом, який пізніше буде використовуватися для зв’язку з клієнтами.

Тепер викличемо цю функцію з index.js,  і сервер буде запущений з початком роботи програми:

index.js
import makeStore from './src/store';
import startServer from './src/server';

export const store = makeStore();
startServer();

Можна трохи спростити процедуру запуску, додавши команду startв наш package.json:

package.json
"scripts": {
  "start": "babel-node index.js",
  "test": "mocha --compilers js:babel-core/register  --require ./test/test_helper.js  --recursive",
  "test:watch": "npm run test -- --watch"
},

Тепер після введення наступної команди буде запускатися сервер і створюватися Redux-Store:

npm run start

Команда babel-node взята з раніше встановленого нами пакету babel-cli . Вона дозволяє легко запускати Node-код з включеною підтримкою Babel-транспіліровання. В цілому, це не рекомендується робити для бойових серверів, тому що продуктивність дещо знижується. Але зате добре підходить для наших навчальних завдань.

4.9. Трансляція Store з Redux Listener

Тепер у нас є сервер Socket.io і контейнер Redux стану, але вони поки ніяк не інтегровані. Змінимо це.

Сервер повинен повідомляти клієнтам про поточний стан програми (наприклад, «за що зараз голосуємо?», «Який поточний результат?», «Чи є вже переможець?»). Це можна робити при кожній зміні за допомогою надіслати інформацію про подію з Socket.io всім підключеним клієнтам.

А як дізнатися, що щось змінилося? Для цього можна підписатися на Redux store, надавши функцію, яка буде викликатися сховищем при кожному застосуванні action, коли стан потенційно змінився. По суті, це callback на зміни стану всередині store.

Ми будемо робити це в startServer, так що надамо йому Redux store для початку:

index.js
import makeStore from './src/store';
import {startServer} from './src/server';

export const store = makeStore();
startServer(store);

Підпишемо одержувача подій (listener) на наше сховище. Він зчитує поточний стан, перетворює його в простий JavaScript-об’єкт і передає його на сервер Socket.io у вигляді події state. В результаті ми отримуємо JSON-серіалізований снепшот стану, що розсилається на всі активні підключення Socket.io.

src/server.js
import Server from 'socket.io';

export function startServer(store) {
  const io = new Server().attach(8090);

  store.subscribe(
    () => io.emit('state', store.getState().toJS())
  );
}

Тепер при кожній зміні ми передаємо повний стан всім клієнтам. Але це може спричинити за собою серйозне зростання трафіку. Можна запропонувати різні способи оптимізації (наприклад, відправляти тільки актуальну частину статків, відправляти діфи замість снепшот, і т.д.). В нашій реалізації не будемо цього робити в цілях збереження простоти коду.

Крім передачі снепшот стану було б добре, якби клієнти негайно отримували поточний стан при підключенні до сервера. Це дозволить відразу синхронізувати стан клієнтських додатків з поточним станом сервера.

На сервері Socket.io ми можемо слухати події connection, що передаються клієнтами при кожному підключенні. У обробнику події ми можемо відразу віддавати поточний стан:

src/server.js
import Server from 'socket.io';

export function startServer(store) {
  const io = new Server().attach(8090);

  store.subscribe(
    () => io.emit('state', store.getState().toJS())
  );

  io.on('connection', (socket) => {
    socket.emit('state', store.getState().toJS());
  });

}

4.10. Отримання Remote Redux Actions

До того ж до передачі клієнтам стану програми, нам потрібно вміти отримувати від них поновлення: користувачі будуть голосувати, а модуль управління голосуванням буде обробляти події за допомогою дії NEXT. Для цього досить безпосередньо згодовувати в Redux store події action, які генеруються клієнтами.

src/server.js
import Server from 'socket.io';

export function startServer(store) {
  const io = new Server().attach(8090);

  store.subscribe(
    () => io.emit('state', store.getState().toJS())
  );

  io.on('connection', (socket) => {
    socket.emit('state', store.getState().toJS());
    socket.on('action', store.dispatch.bind(store));
  });

}

Тут ми вже виходимо за рамки «стандартного Redux», тому що фактично приймаємо в store вилучені (remote) actions. Але архітектура Redux зовсім не заважає нам: дії є JavaScript-об’єктами, які можна легко посилати по мережі, тому ми відразу отримуємо систему, в якій братимуть участь в голосуванні може будь-яку кількість клієнтів. А це великий крок!

Звичайно, з точки зору безпеки тут є ряд моментів, адже ми дозволяємо будь-якому клієнту, підключився до Socket.io, відправляти будь-яку дію в Redux store. Тому в реальних проектах потрібно використовувати щось на зразок файрволу, на зразок Vert.x Event Bus Bridge . Також файрвол потрібно впроваджувати в додатки з механізмом аутентифікації.

Тепер наш сервер працює наступним чином:

  1. Клієнт відправляє на сервер якусь дію (action).
  2. Сервер пересилає його в Redux store.
  3. Store викликає reducer, який виконує логіку, пов’язану з цим action.
  4. Store оновлює стан на підставі повертається reducer-му значення.
  5. Store виконує відповідний listener, підписаний сервером.
  6. Сервер генерує подія state.
  7. Всі підключені клієнти – включаючи того, хто ініціював початкове дію – отримують новий стан.

Перш, ніж ми закінчимо роботу над сервером, давайте завантажимо в нього тестовий набір записів, щоб подивитися, як працює система. Записи можна помістити в файл entries.json. Нехай це буде список фільмів Денні Бойла.

entries.json
[
  "Shallow Grave",
  "Trainspotting",
  "A Life Less Ordinary",
  "The Beach",
  "28 Days Later",
  "Millions",
  "Sunshine",
  "Slumdog Millionaire",
  "127 Hours",
  "Trance",
  "Steve Jobs"
]

Далі просто завантажуємо список в index.js, а потім запускаємо голосування за допомогою дії NEXT:

index.js
import makeStore from './src/store';
import {startServer} from './src/server';

export const store = makeStore();
startServer(store);

store.dispatch({
  type: 'SET_ENTRIES',
  entries: require('./entries.json')
});
store.dispatch({type: 'NEXT'});

Тепер можна перейти до клієнтського додатку.

5. Клієнтський додаток

Далі ми будемо писати React-додаток, який підключається до сервера і дозволяє користувачам голосувати. І тут ми теж скористаємося Redux. Власне, це одна з найбільш поширених його застосувань: як движка в підставі React-додатків. Ми вже познайомилися з його роботою, і скоро дізнаємося, як він поєднується з React і яке впливає на архітектуру.

5.1. Налаштування клієнтського проекту

В першу чергу ми створимо свіжий NPM-проект, як ми це робили у випадку з сервером.

mkdir voting-client
cd voting-client
npm init –y

Тепер для нашого застосування потрібна стартова HTML-сторінка. Покладемо її в dist/index.html:

dist/index.html
<!DOCTYPE html>
<html>
<body>
  <div id="app"></div>
  <script src="bundle.js"></script>
</body>
</html>

Документ містить лише <div>з ID app, сюди ми і помістимо наш додаток. В ту ж папку треба буде покласти і файл bundle.js.

Створимо перший JavaScript-файл, який стане вхідний точкою докладання. Поки що можна просто помістити в нього простий вираз:

src/index.js
console.log('I am alive!');

Для полегшення процесу створення програми скористаємося Webpack і його сервером розробки, додавши їх до нашого проекту:

npm install --save-dev webpack webpack-dev-server

Якщо ви їх поки не встановлювали, варто встановити ці ж пакети глобально, щоб можна було зручно запускати все необхідне з командного рядка: npm install -g webpack webpack-dev-server.

Додамо файл конфігурації Webpack, відповідні створеним раніше файлам, в корінь проекту:

webpack.config.js
module.exports = {
  entry: [
    './src/index.js'
  ],
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist'
  }
};

Він виявить нашу вхідну точку index.js і вмонтує все необхідне в бандл dist/bundle.js. Папка distбуде базовою і для сервера розробки.

Тепер можна запустити webpack для створення bundle.js:

webpack

Далі запустимо сервер, після чого тестова сторінка стане доступна в localhost: 8080 (включаючи вираз з index.js).

webpack-dev-server

Оскільки ми зібралися використовувати в клієнтському коді React JSX синтаксис і ES6, то нам потрібна ще пара інструментів. Babel вміє працювати з ними обома, тому підключимо його і його Webpack-завантажувач:

npm install --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react

Включаємо в package.jsonпідтримку Babel’ем ES6 / ES2015 і React JSX, активуючи тільки що встановлені пресети:

package.json
"babel": {
  "presets": ["es2015", "react"]
}

Тепер змінимо конфігураційний файл Webpack, щоб він міг знайти .jsx і .jsфайли і обробити їх за допомогою Babel:

webpack.config.js
module.exports = {
  entry: [
    './src/index.js'
  ],
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'babel'
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist'
  }
};

Не будемо витрачати час на CSS. Якщо ви хочете зробити додаток гарніше, то можете самі додати в нього стилі.

5.1.1. Підтримка модульного тестування

Для клієнтського коду ми теж будемо писати модульні тести. Для цього скористаємося тими ж бібліотеками – Mocha і Chai:

npm install --save-dev mocha chai

Також будемо тестувати і React-компоненти, для чого нам знадобиться DOM. В якості альтернативи можна запропонувати прогнати в цьому веб-браузері тести бібліотекою зразок Karma . Але це не є необхідністю для нас, оскільки ми можемо обійтися засобами jsdom , реалізацією DOM на чистому JavaScript всередині Node:

npm install --save-dev jsdom

Для останньої версії jsdom потрібно io.js або Node.js 4.0.0. Якщо ви користуєтеся більш старою версією Node, то вам доведеться встановити і старіший jsdom:

npm install --save-dev jsdom@3 

Також нам знадобиться кілька рядків настройки jsdom для використання React. Зокрема, створимо jsdom-версії об’єктів documentі window, що надаються браузером. Потім покладемо їх у глобальний об’єкт , щоб React міг знайти їх, коли буде звертатися до document або window. Для цієї настройки підготуємо допоміжний тестовий файл:

test/test_helper.js
import jsdom from 'jsdom';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

Крім того, нам потрібно взяти все властивості, що містяться в jsdom-об’єкті window(наприклад, navigator), і додати їх у об’єкт global в Node.js. Це робиться для того, щоб надані об’єктом windowвластивості можна було використовувати без префікса window., як це відбувається в браузерному оточенні. Від цього залежить частина коду всередині React:

test/test_helper.js
import jsdom from 'jsdom';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

Object.keys(window).forEach((key) => {
  if (!(key in global)) {
    global[key] = window[key];
  }
});

Також ми скористаємося Immutable колекціями, тому доведеться вдатися до тієї ж виверту, що і у випадку з сервером, щоб впровадити підтримку Chai. Встановимо обидва пакети – immutable і chai-immutable:

npm install --save immutable
npm install --save-dev chai-immutable

Далі пропишемо їх в тестовому допоміжному файлі:

test/test_helper.js
import jsdom from 'jsdom';
import chai from 'chai';
import chaiImmutable from 'chai-immutable';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

Object.keys(window).forEach((key) => {
  if (!(key in global)) {
    global[key] = window[key];
  }
});

chai.use(chaiImmutable);

Останній крок перед запуском тестів: додамо в файл package.jsonкоманду для їх запуску:

package.json
"scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js \"test/**/*@(.js|.jsx)\""
},

Майже таку ж команду ми використовували в серверному package.json. Різниця лише в специфікації тестового файлу: на сервері ми використовували --recursive, але в цьому випадку не будуть виявлятися .jsx-файли. Для можлівості знайти і .js, і .jsx-файли використовуємо glob .

Було б зручно безперервно проганяти тести при будь-яких змінах в коді. Для цього можна додати команду test:watch, ідентичну застосовуваної на сервері:

package.json
"scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js 'test/**/*.@(js|jsx)'",
  "test:watch": "npm run test -- --watch"
},

5.2. React і react-hot-loader

Інфраструктура Webpack і Babel готова, займемося React!

При побудові React-додатків за допомогою Redux і Immutable ми можемо писати так звані чисті компоненти (Pure Components, їх ще іноді називають Dumb Components). Ідея та ж, що і в основі чистих функцій, повинні дотримуватися два правила:

  1. Чистий компонент отримує всі дані у вигляді властивостей, як функція отримує дані у вигляді аргументів. Не повинно бути ніяких побічних ефектів – читання даних звідки або, ініціації мережевих запитів і т.д.
  2. В цілому у чистого компонента немає внутрішнього стану. Отрісовка залежить виключно від вхідних властивостей. Якщо двічі щось отрисовать за допомогою одного компонента, що має одні й ті ж властивості, то в результаті ми отримаємо один і той же інтерфейс. У компонента немає прихованого стану, яке може вплинути на процес відтворення.

Використання чистих компонентів спрощує код, як і використання чистих функцій: ми можемо зрозуміти, що робить компонент, подивившись на його вхідні дані і результат відтворення. Більше нам нічого не потрібно знати про компоненті. Тестувати його також не складно, майже як і тестувати логіку додатку на основі чистих функцій.

Але якщо компонент не може володіти станом, то де воно буде знаходитися? У незмінної структурі даних всередині Redux store! Відокремити стан від коду призначеного для користувача інтерфейсу – відмінна ідея.

Але не будемо забігати вперед. Додамо React в наш проект:

npm install --save react react-dom

Також налаштуємо react-hot-loader . Цей інструмент сильно прискорить процес розробки завдяки перезавантаження коду без втрати поточного стану програми.

npm install --save-dev react-hot-loader

Було б нерозумно нехтувати react-hot-loader, адже наша архітектура тільки заохочує його використання. По суті, створення Redux і react-hot-loader – дві частини однієї історії !

Для підтримки цього завантажувача зробимо кілька оновлень в webpack.config.js. Ось що вийшло:

webpack.config.js
var webpack = require('webpack');

module.exports = {
  entry: [
    'webpack-dev-server/client?http://localhost:8080',
    'webpack/hot/only-dev-server',
    './src/index.js'
  ],
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'react-hot!babel'
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist',
    hot: true
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
};

У секцію entry включено дві нові речі для вхідних точок нашого застосування: клієнтська бібліотека від Webpack сервера розробки і завантажувач модулів (hot module loader) Webpack. Завдяки цьому ми зможемо використовувати інфраструктуру Webpack для гарячої заміни модулів . За замовчуванням така заміна не підтримується, тому в секції plugins доведеться довантажувати відповідний плагін і активувати підтримку в секції devServer.

У секції loaders ми налаштовуємо завантажувач react-hot, щоб він поряд з Babel міг працювати з файлами .js і .jsx.

Тепер при запуску або рестарт сервера розробки ми побачимо в консолі повідомлення про включення підтримки гарячої заміни модулів (Hot Module Replacement).

5.3. Створення призначеного для користувача інтерфейсу для екрану голосування

Цей екран буде дуже простим: поки голосування не завершилося, завжди будуть відображатися дві кнопки, по одній для кожної з двох записів. А по завершенні голосування буде показаний переможець.

Interface

Здебільшого поки ми займалися розробкою через тестування, при створенні React-компонентів застосуємо інший підхід: спочатку пишемо компоненти, а потім тести. Справа в тому, що Webpack і react-hot-loader мають ще більш короткий контур зворотного зв’язку , ніж модульні тести. Крім того, при створенні інтерфейсу немає нічого ефективнішого, ніж спостерігати його роботу своїми очима.

Припустимо, нам потрібно створити компонент Voting і рендерити його в якості вхідної точки. Можна змонтувати його в div #app, який раніше був доданий в index.html. І доведеться перейменуватиindex.jsв index.jsx, адже тепер він містить JSX-розмітку:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

ReactDOM.render(
  <Voting pair={pair} />,
  document.getElementById('app')
);

Компонент Voting отримує пару записів у вигляді властивостей. Поки що ми цю пару захардкодім, а пізніше замінимо реальними даними. Компонент чистий, тому йому не важливо, звідки беруться дані.

Змінимо ім’я стартового файлу в webpack.config.js:

webpack.config.js
entry: [
  'webpack-dev-server/client?http://localhost:8080',
  'webpack/hot/only-dev-server',
  './src/index.jsx'
],

Тепер при запуску або рестарт webpack-dev-server ми побачимо повідомлення про відсутність компонента Voting. Напишемо його першу версію:

src/components/Voting.jsx
import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

Пара записів виводяться у вигляді кнопок, їх можна побачити в браузері. Спробуйте внести в код компонента якісь зміни, вони негайно з’являться в браузері. Без перезапусків і перезавантажень сторінки. Це до питання про швидкість зворотного зв’язку.

Якщо ви бачите не те, що очікуєте, то перевірте вихідні дані webpack-dev-server, а також лог браузера.

Тепер можна додати перший модульний тест. Він буде розташований в файлі Voting_spec.jsx:

test/components/Voting_spec.jsx
import Voting from '../../src/components/Voting';

describe('Voting', () => {

});

Для перевірки відтворення кнопок по властивості pair, потрібно отрендерити компонент і перевірити результат. Для цього скористаємося допоміжною функцією renderIntoDocument з пакету тестових утиліт React, який спочатку потрібно встановити:

npm install --save react-addons-test-utils
test/components/Voting_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument
} from 'react-addons-test-utils';
import Voting from '../../src/components/Voting';

describe('Voting', () => {

  it('renders a pair of buttons', () => {
    const component = renderIntoDocument(
      <Voting pair={["Trainspotting", "28 Days Later"]} />
    );
  });

});

Після відтворення компонента для пошуку кнопок можна використовувати іншу допоміжну функцію React – scryRenderedDOMComponentsWithTag . Їх повинно бути дві, а що текстовий вміст елементів має збігатися з нашими двома записами.

test/components/Voting_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithTag
} from 'react-addons-test-utils';
import Voting from '../../src/components/Voting';
import {expect} from 'chai';

describe('Voting', () => {

  it('renders a pair of buttons', () => {
    const component = renderIntoDocument(
      <Voting pair={["Trainspotting", "28 Days Later"]} />
    );
    const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

    expect(buttons.length).to.equal(2);
    expect(buttons[0].textContent).to.equal('Trainspotting');
    expect(buttons[1].textContent).to.equal('28 Days Later');
  });

});

Запускаємо тест і перевіряємо:

npm run test

При кліку на будь-яку кнопку компонент повинен викликати callback-функцію. Вона повинна бути передана компоненту у вигляді властивості, як і пара записів. Додамо в тест відповідну перевірку. Емулюючи клік за допомогою об’єкта Simulate з тестових утиліт React:

test/components/Voting_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithTag,
  Simulate
} from 'react-addons-test-utils';
import Voting from '../../src/components/Voting';
import {expect} from 'chai';

describe('Voting', () => {

  // ...

  it('invokes callback when a button is clicked', () => {
    let votedWith;
    const vote = (entry) => votedWith = entry;

    const component = renderIntoDocument(
      <Voting pair={["Trainspotting", "28 Days Later"]}
              vote={vote}/>
    );
    const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
    Simulate.click(buttons[0]);

    expect(votedWith).to.equal('Trainspotting');
  });

});

Написати цей тест не складно. Для кнопок нам лише потрібен обробник onClick, що викликає vote до здійснення неправильного запису:

src/components/Voting.jsx
import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

Таким чином ми за допомогою чистих компонентів будемо управляти призначеним для користувача введенням і діями: компоненти не будуть самостійно обробляти actions, а будуть просто викликати callback-й.

Тут ми повернулися до розробки через тестування. В ході створення інтерфейсу ми будемо ще не раз перемикатися з одного підходу на інший, залежно того, що буде корисніше в поточних обставинах.

Коли користувач проголосував за якусь позицію, не варто дозволяти йому робити це повторно. Ми могли б обробити цю ситуацію всередині стану компонента, але оскільки намагаємося зберігати компоненти чистими, винесемо цю логіку назовні. Компонент отримає властивість hasVoted, і обраний елемент ми поки захардкодім:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

ReactDOM.render(
  <Voting pair={pair} hasVoted="Trainspotting" />,
  document.getElementById('app')
);

І допишемо компонент голосування:

src/components/Voting.jsx
import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

Додамо невеликий label на кнопку, який буде ставати видимим при отриманні властивості hasVoted. Зробимо допоміжний метод hasVotedFor, який буде вирішувати, чи потрібно його малювати:

src/components/Voting.jsx
import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
          {this.hasVotedFor(entry) ?
            <div className="label">Voted</div> :
            null}
        </button>
      )}
    </div>;
  }
});

Коли у нас з’явиться фінальний переможець, то відображатися буде тільки він. Для нього ми зробимо інше властивість, значення якого також тимчасово захардкодім:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

ReactDOM.render(
  <Voting pair={pair} winner="Trainspotting" />,
  document.getElementById('app')
);

Чи можемо обробити це в компоненті, відмальовуя div переможця або кнопки вибору в залежності від значення властивості winner:

src/components/Voting.jsx
import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.props.winner ?
        <div ref="winner">Winner is {this.props.winner}!</div> :
        this.getPair().map(entry =>
          <button key={entry}
                  disabled={this.isDisabled()}
                  onClick={() => this.props.vote(entry)}>
            <h1>{entry}</h1>
            {this.hasVotedFor(entry) ?
              <div className="label">Voted</div> :
              null}
          </button>
        )}
    </div>;
  }
});

Тепер ми отримали потрібну нам функціональність, але код відтворення поки виглядає трохи неохайно. Краще взяти з нього окремі компоненти, щоб компонент екрану голосування (vote screen) малював або компонент переможця (winner), або компонент голосування (vote). У разі компонента winner буде відмальовуватись просто div:

src/components/Winner.jsx
import React from 'react';

export default React.createClass({
  render: function() {
    return <div className="winner">
      Winner is {this.props.winner}!
    </div>;
  }
});

Компонент голосування буде практично таким же, як і раніше, потрібні лише кнопки голосування:

src/components/Vote.jsx
import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
          {this.hasVotedFor(entry) ?
            <div className="label">Voted</div> :
            null}
        </button>
      )}
    </div>;
  }
});

А сам компонент голосування тепер просто приймає рішення, який з двох компонентів потрібно відмальовувати:

src/components/Voting.jsx
import React from 'react';
import Winner from './Winner';
import Vote from './Vote';

export default React.createClass({
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

Зверніть увагу, що в компонент переможці доданий ref . Ми будемо використовувати його в модульних тестах для отримання необхідного DOM-елемента.

У нас готовий чистий компонент голосування! Зауважте, ми до сих пір не реалізували ніяку логіку: є тільки кнопки, які поки нічого не роблять, за винятком виклику callback-ів. Компоненти відповідальні лише за відмальовку інтерфейсу. Пізніше ми додамо логіку додатка, підключивши інтерфейс до Redux store.

Тепер напишемо ще кілька модульних тестів для перевірки нової функціональності. Наявність властивості hasVoted має приводити до відключення кнопок голосування:

test/components/Voting_spec.jsx
it('отключает кнопку, как только пользователь проголосует', () => {
  const component = renderIntoDocument(
    <Voting pair={["Trainspotting", "28 Days Later"]}
            hasVoted="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

  expect(buttons.length).to.equal(2);
  expect(buttons[0].hasAttribute('disabled')).to.equal(true);
  expect(buttons[1].hasAttribute('disabled')).to.equal(true);
});

Label Voted з’являється на тій кнопці, чия запис збігається зі значенням властивості hasVoted:

test/components/Voting_spec.jsx
it('добавляет label к записи, за которую проголосовали', () => {
  const component = renderIntoDocument(
    <Voting pair={["Trainspotting", "28 Days Later"]}
            hasVoted="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

  expect(buttons[0].textContent).to.contain('Voted');
});

Коли у нас з’являється переможець, то повинні відмалюватися НЕ кнопки, а елемент з ref’ом переможця:

test/components/Voting_spec.jsx
it('отрисовывает только победителя', () => {
  const component = renderIntoDocument(
    <Voting winner="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
  expect(buttons.length).to.equal(0);

  const winner = ReactDOM.findDOMNode(component.refs.winner);
  expect(winner).to.be.ok;
  expect(winner.textContent).to.contain('Trainspotting');
});

Можна було б написати тести для кожного компонента окремо, але я вважаю, що в даному випадку правильніше тестувати екран голосування в якості «модуля». Ми тестуємо зовнішню поведінку компонента, а той факт, що всередині нього є більш дрібні компоненти, це вже деталі реалізації.

5.4. Незмінні дані і чистий рендеринг (Pure Rendering)

Ми вже обговорили основні переваги незмінних даних, але є ще одне, вкрай практична перевага, пов’язана з їх використанням разом з React. Якщо в якості властивостей компонента ми будемо використовувати тільки незмінні дані, а самі компоненти напишемо відповідно до критеріїв чистоти, то змусимо React застосовувати більш ефективну стратегію виявлення змін у властивостях.

Для цього застосуємо PureRenderMixin з add-on-пакета . Якщо додати mixin в компонент, то React стане по-іншому перевіряти властивості (і стан) компонента на наявність змін. Порівняння буде не глибоким, а поверхневим, що набагато швидше.

Доцільність цього рішення полягає в тому, що змін в immutable структурах бути не може. Так що якщо властивості компонента є незмінні дані, і вони вказують на ті ж значення між відмальовками, то немає необхідності рендерить компонент знову!

Давайте напишемо модульні тести на цей випадок. Передбачається, що у нас чистий компонент, так що якщо дати йому змінюваний масив, а потім зробити в ньому якусь зміну, то компонент не повинен   бути перемальованим:

test/components/Voting_spec.jsx
it('отрисовывается как чистый компонент', () => {
  const pair = ['Trainspotting', '28 Days Later'];
  const container = document.createElement('div');
  let component = ReactDOM.render(
    <Voting pair={pair} />,
    container
  );

  let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
  expect(firstButton.textContent).to.equal('Trainspotting');

  pair[0] = 'Sunshine';
  component = ReactDOM.render(
    <Voting pair={pair} />,
    container
  );
  firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
  expect(firstButton.textContent).to.equal('Trainspotting');
});

Замість renderIntoDocument ми вручну створюємо батьківський <div> і двічі відмальовуваємих в нього, емулюючи перемальовку.

Потрібно явно задати у властивостях новий незмінний список, щоб зміни відбулися в інтерфейсі:

test/components/Voting_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithTag,
  Simulate
} from 'react-addons-test-utils';
import {List} from 'immutable';
import Voting from '../../src/components/Voting';
import {expect} from 'chai';

describe('Voting', () => {

  // ...

  it('обновляет DOM при изменении свойства', () => {
    const pair = List.of('Trainspotting', '28 Days Later');
    const container = document.createElement('div');
    let component = ReactDOM.render(
      <Voting pair={pair} />,
      container
    );

    let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
    expect(firstButton.textContent).to.equal('Trainspotting');

    const newPair = pair.set(0, 'Sunshine');
    component = ReactDOM.render(
      <Voting pair={newPair} />,
      container
    );
    firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
    expect(firstButton.textContent).to.equal('Sunshine');
  });

});

Зазвичай я не переймаюсь написанням подібних тестів, а просто припускаю використання PureRenderMixin. Але в нашому випадку тести просто допомагають розібратися в тому, що відбувається. Тут вони демонструють, що компонент поводиться не так, як очікується: оновлення інтерфейсу відбувається в обох випадках. Це означає проведення глибоких перевірок властивостей, чого ми якраз і хотіли уникнути за допомогою незмінних даних.

Все стає на свої місця після того, як ми включимо PureRenderMixin в нашому компоненті. Спочатку встановимо пакет:

npm install --save react-addons-pure-render-mixin

Після його додавання в компоненти тести починають виконуватися успішно:

src/components/Voting.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Winner from './Winner';
import Vote from './Vote';

export default React.createClass({
  mixins: [PureRenderMixin],
  // ...
});

src/components/Vote.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  // ...
});

src/components/Winner.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  // ...
});

Строго кажучи, ми почнемо проходити тести навіть при простому включенні PureRenderMixin в компоненті голосування, не звертаючи уваги на інші два компонента. Справа в тому, що коли React не знаходить змін у властивостях Voting, то він пропускає перемальовку всього піддерева компоненту.

Але все ж правильніше буде послідовно використовувати PureRenderMixin у всіх компонентах. По-перше, це підтвердить їх чистоту, а по-друге, їх поведінка не зміниться навіть після перегруповування.

5.5. Створення призначеного для користувача інтерфейсу для екрану результатів і обробка переходів (Routing Handling)

Закінчили з екраном голосування, тепер перейдемо до іншого важливого екрану нашого застосування: до екрану відображення результатів.

В якості даних для відображення використовуються ті ж пари записів, що і на екрані голосування, а також поточні лічильники голосів по кожному запису. Внизу додається маленька кнопка, при натисканні якої ми переходимо до голосування по наступній парі.

Виходить, що у нас є два окремих екрана, і в кожен момент часу повинен відображатися один з них. Для вибору конкретного екрану для відображення можна використовувати URL’и. Призначимо шлях #/для відображення екрану голосування, а шлях #/results– для відображення екрану результатів.

Подібні речі легко виконуються за допомогою бібліотеки react-router, Завдяки якій можна асоціювати один з одним різні компоненти і шляхи. Додамо її в наш проект:

npm install --save react-router@2.0.0

Тепер сконфігуріруем шляхи. Для цього скористаємося роутером (Router) з React-компонента Route, за допомогою якого декларативно опишемо таблицю відповідностей. Поки що у нас є лише один шлях:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Route} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

const routes = <Route component={App}>
  <Route path="/" component={Voting} />
</Route>;

ReactDOM.render(
  <Voting pair={pair} />,
  document.getElementById('app')
);

У нас є один шлях, який вказує на компонент Voting. Також ми визначили компонент кореневого шляху, який може використовуватися всіма конкретними шляхами всередині нього. Він вказує на компонент App, який скоро буде створений.

Завдання компонента кореневого шляху полягає в відображенні загальної для всіх розмітки. Так має виглядати наш кореневої компонент App:

src/components/App.jsx
import React from 'react';
import {List} from 'immutable';

const pair = List.of('Trainspotting', '28 Days Later');

export default React.createClass({
  render: function() {
    return React.cloneElement(this.props.children, {pair: pair});
  }
});

Цей компонент відмальовує свої дочірні компоненти, що передаються у властивості children. Далі react-router підключає компоненти, визначені для поточного маршруту. Оскільки поки у нас є тільки один маршрут для Voting, то на даний момент компонент завжди буде малювати Voting.

Зверніть увагу, що заглушка pair переміщених з index.jsx у App.jsx. Для клонування вихідних компонентів з передачею кастомних властивостей pair ми скористаємося API cloneElement. Це тимчасовий захід, пізніше можна буде прибрати клонуючий виклик.

Вище ми говорили про те, що краще використовувати PureRenderMixin у всіх компонентах. Винятком з цього правила є компонент App: через особливості взаємодії між роутером і React маршрути можуть не змінитися. Можливо, в найближчому майбутньому ситуація зміниться.

Тепер повернемося до index.js, з якого запустимо сам роутер, щоб вони Ініціалізувати наш додаток:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';

const routes = <Route component={App}>
  <Route path="/" component={Voting} />
</Route>;

ReactDOM.render(
  <Router history={hashHistory}>{routes}</Router>,
  document.getElementById('app')
);

Компонент Router з пакета react-routerє кореневим для нашого застосування, і він буде використовувати механізм збереження історії на базі #hash (на відміну від API для збереження історії в HTML 5). Передамо йому нашу таблицю відповідностей у вигляді дочірнього компонента.

Тепер ми відновили попередню функціональність нашого застосування: воно всього лише відмальовує компонент Voting. Але в цей раз це робиться за допомогою роутера React, а значить ми легко можемо додавати нові шляхи. Зробимо це для екрану результатів, який буде обслуговуватися новим компонентом Results:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';
import Results from './components/Results';

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={Voting} />
</Route>;

ReactDOM.render(
  <Router history={hashHistory}>{routes}</Router>,
  document.getElementById('app')
);

Тут задали в компоненті <Route> для шляху /results відмальовку компонента results. Всі інші шляхи ведут до Voting.

Давайте створимо просту реалізацію Resultsта подивимося на роботу роутінга:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>Hello from results!</div>
  }
});

Якщо відкрити в браузері localhost:8080/#/results, то ви побачите повідомлення від компонента Results. Корневной маршрут повинен відобразити кнопки голосування. За допомогою кнопок «вперед» і «назад» в браузері ви можете перемикатися між шляхами, і відображається компонент буде змінюватися. Ось він, роутер в дії!

Більше в нашому додатку ми нічого не будемо робити за допомогою роутера React. Хоча у бібліотеки куди більше можливостей, можете подивитися її документацію .

Тепер, коли у нас є тимчасовий компонент Results, давайте змусимо його зробити щось корисне. Нехай він відображає ті ж два записи, які зараз беруть участь в голосуванні в компоненті Voting:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="results">
      {this.getPair().map(entry =>
        <div key={entry} className="entry">
          <h1>{entry}</h1>
        </div>
      )}
    </div>;
  }
});

Раз це екран результатів, то потрібно відобразити поточний розподіл голосів, адже саме це люди очікують побачити. Передамо в компонент з кореневого компонента App тимчасовий результат голосування Map:

src/components/App.jsx
import React from 'react';
import {List, Map} from 'immutable';

const pair = List.of('Trainspotting', '28 Days Later');
const tally = Map({'Trainspotting': 5, '28 Days Later': 4});

export default React.createClass({
  render: function() {
    return React.cloneElement(this.props.children, {
      pair: pair,
      tally: tally
    });
  }
});

Тепер налаштуємо компонент Results для відображення цих чисел:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return <div className="results">
      {this.getPair().map(entry =>
        <div key={entry} className="entry">
          <h1>{entry}</h1>
          <div className="voteCount">
            {this.getVotes(entry)}
          </div>
        </div>
      )}
    </div>;
  }
});

А тепер давайте змінимо передачу і додамо модульний тест для поточного поведінки компонента Results, щоб упевнитися, що пізніше ми його не зламаємо. Компонент повинен малювати div’и для кожного запису, всередині яких відображати імена самих записів і поточну кількість голосів. Якщо за запис ніхто не проголосував, то нехай відображається нуль:

test/components/Results_spec.jsx
import React from 'react';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithClass
} from 'react-addons-test-utils';
import {List, Map} from 'immutable';
import Results from '../../src/components/Results';
import {expect} from 'chai';

describe('Results', () => {

  it('renders entries with vote counts or zero', () => {
    const pair = List.of('Trainspotting', '28 Days Later');
    const tally = Map({'Trainspotting': 5});
    const component = renderIntoDocument(
      <Results pair={pair} tally={tally} />
    );
    const entries = scryRenderedDOMComponentsWithClass(component, 'entry');
    const [train, days] = entries.map(e => e.textContent);

    expect(entries.length).to.equal(2);
    expect(train).to.contain('Trainspotting');
    expect(train).to.contain('5');
    expect(days).to.contain('28 Days Later');
    expect(days).to.contain('0');
  });

});

Тепер поговоримо про кнопку «Next», використовуваної для переходу до наступного голосування. З точки зору компонента, у властивостях повинна бути просто callback-функція. Вона повинна викликатися компонентом, коли всередині нього натискається кнопка «Next». Сформулюємо досить простий модульний тест, вельми схожий на той, що ми робили для кнопок голосування:

test/components/Results_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithClass,
  Simulate
} from 'react-addons-test-utils';
import {List, Map} from 'immutable';
import Results from '../../src/components/Results';
import {expect} from 'chai';


describe('Results', () => {

  // ...

  it('вызывает callback при нажатии кнопки Next', () => {
    let nextInvoked = false;
    const next = () => nextInvoked = true;

    const pair = List.of('Trainspotting', '28 Days Later');
    const component = renderIntoDocument(
      <Results pair={pair}
               tally={Map()}
               next={next}/>
    );
    Simulate.click(ReactDOM.findDOMNode(component.refs.next));

    expect(nextInvoked).to.equal(true);
  });

});

Реалізація багато в чому схожа з кнопками голосування. Вийшло трохи простіше, оскільки не потрібно передавати аргументи:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return <div className="results">
      <div className="tally">
        {this.getPair().map(entry =>
          <div key={entry} className="entry">
            <h1>{entry}</h1>
            <div class="voteCount">
              {this.getVotes(entry)}
            </div>
          </div>
        )}
      </div>
      <div className="management">
        <button ref="next"
                className="next"
                onClick={this.props.next}>
          Next
        </button>
      </div>
    </div>;
  }
});

Як і у випадку з екраном голосування, на екрані результатів повинен відобразитися переможець:

test/components/Results_spec.jsx
it('отрисовывает финального победителя', () => {
  const component = renderIntoDocument(
    <Results winner="Trainspotting"
             pair={["Trainspotting", "28 Days Later"]}
             tally={Map()} />
  );
  const winner = ReactDOM.findDOMNode(component.refs.winner);
  expect(winner).to.be.ok;
  expect(winner.textContent).to.contain('Trainspotting');
});

Можна реалізувати це за допомогою повторного використання компонента Winner, вже розробленого для екрану голосування. Як тільки у нас визначається фінальний переможець, то ми відмальовуємо відповідний компонент замість стандартного екрану результатів:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Winner from './Winner';

export default React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
        </div>
      </div>;
  }
});

Цьому компоненту також пішло б на користь поділ на більш дрібні складові. Наприклад, компонент Tally міг би відображати пари записів. Якщо вам подобається ця ідея, то сміливо рефакторьте!

І це майже весь інтерфейс, необхідний нашому простому додатку. Поки що написані нами компоненти нічого не роблять, тому що не отримують реальних даних або дій. Примітно, як далеко нам вдається зайти і без цього. Ми навіть змогли впровадити в ці компоненти прості заглушки, щоб сконцентруватися на структурі інтерфейсу.

Тепер, коли ми завершили його створення, поговоримо про те, як вдихнути в нього життя за допомогою підключення Redux store до вхідних і вихідних каналів.

5.6. Використання клієнтського Redux Store

Redux був спроектований для використання в якості контейнера станів додатків, що мають користувальницький інтерфейс. Наш додаток повністю підходить під цей критерій. Поки що ми використовували Redux тільки на сервері і з’ясували, що він і там дуже корисний! Тепер можна подивитися, як він поведе себе з React-додатком.

У нас є інтерфейс з двома екранами. На обох відображається пара записів, які беруть участь в голосуванні. Має сенс зробити стан vote з парою елементів для голосування:

interface

В той же стан помістимо екран результатів, куди виводяться поточний розподіл голосів.

interface

Компонент голосування (Voting) відмальовуеться інакше, коли користувач вже проголосував в поточній парі. Це також має відстежуватися станом:

interface

Коли з’являється фінальний переможець, тільки він і повинен бути присутнім в стані:

interface

Зверніть увагу, що все тут є підмножиною станів сервера, за винятком суті hasVoted. Це призводить нас до думок про реалізацію основної логіки, дій (actions) і перетворювачів (reducers), які будуть використовуватися Redux store. Якими вони повинні бути?

Давайте розглянемо це з точки зору того, що може змінити стан виконання додатку. Одне джерело змін стану – дії користувача. Зараз в інтерфейсі передбачено два можливих сценарії взаємодії:

  • Користувач клацає на одну з кнопок на екрані голосування.
  • Користувач клацає на кнопку “Next” на екрані результатів.

Крім того, наш сервер налаштований на відправку свого поточного стану. Скоро ми напишемо код для його отримання. Це третє джерело зміни стану.

Можна почати з оновлення стану сервера, оскільки зробити це найпростіше. Вище ми вже налаштовували наш сервер, щоб він генерував подія state, чиє корисне навантаження являє собою практично точну копію намальованих нами клієнтських дерев станів. Це не збіг, саме таким ми його і розробили. З точки зору нашого клієнтського reducer-a, доцільно мати action, який одержує від сервера снепшот стану і об’єднує його з клієнтським станом. Цей action виглядав би так само:

{
  type: 'SET_STATE',
  state: {
    vote: {...}
  }
}

Подивимося за допомогою модульних тестів, як це працює. Отримавши дію, на зразок наведеного вище, reducer повинен об’єднувати його дані з поточним станом:

test/reducer_spec.js
import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {

  it('handles SET_STATE', () => {
    const initialState = Map();
    const action = {
      type: 'SET_STATE',
      state: Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({Trainspotting: 1})
        })
      })
    };
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }));
  });

});

Reducer повинен вміти отримувати від сокета просту JS-структуру даних. Вона повинна бути перетворена в незмінну структуру до моменту свого повернення у вигляді наступного значення:

test/reducer_spec.js
it('обрабатывает SET_STATE с простой JS-нагрузкой', () => {
  const initialState = Map();
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }
  };
  const nextState = reducer(initialState, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

Початковий стан undefined також має бути коректно Ініціалізуваний reducer-м у вигляді незмінної структури:

test/reducer_spec.js
it('обрабатывает SET_STATE без начального состояния', () => {
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }
  };
  const nextState = reducer(undefined, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

Такі наші технічні умови. Давайте подивимося, як їх можна виконати. У нас є функція-reducer, експортована reducer-модулем:

src/reducer.js
import {Map} from 'immutable';

export default function(state = Map(), action) {

  return state;
}

Reducer повинен обробити action SET_STATE. За допомогою функції merge з Map можна просто об’єднати новий стан зі старим в функції-обробнику. Це дозволить нам пройти тести!

src/reducer.js
import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return setState(state, action.state);
  }
  return state;
}

Зверніть увагу, що ми не стали морочитися з «основним» модулем, відокремленим від reducer-модуля. Це пов’язано з тим, що логіка в перетворювачі настільки проста, що про неї не потрібно хвилюватися. Просто виконуємо злиття, в той час як на сервері знаходиться повна логіка системи голосування. Якщо виникне необхідність, можна буде пізніше і на клієнті розділити функціональності.

У нас залишилося ще дві причини зміни стану, пов’язані з діями користувача: голосування і натискання кнопки «Next». В обох випадках мається на увазі взаємодію з сервером, тому ми повернемося до цього трохи пізніше, коли розберемося з архітектурою підключення до сервера.

Настав час додати Redux в наш проект:

npm install --save redux

Хорошим місцем для ініціалізації store є вхідні точка index.jsx. Створимо його з яким-небудь станом, передавши дію SET_STATE(це тимчасове рішення, поки у нас не з’являться реальні дані):

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import reducer from './reducer';
import App from './components/App';
import Voting from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={Voting} />
</Route>;

ReactDOM.render(
  <Router history={hashHistory}>{routes}</Router>,
  document.getElementById('app')
);

Store готовий. Як нам тепер передати з нього дані в React-компоненти?

5.7. Передача вхідних даних з Redux в React

У Redux Store міститься незмінний стан додатку. У нас є React-компоненти, які беруть незмінні дані на вході. Якщо ми зможемо придумати спосіб надійно передавати актуальні дані з store в компоненти, то буде чудово. При змінах стану React буде перемальовуватись, а PureRenderMixin буде стежити за тим, щоб не перемальовували ті частини інтерфейсу, які не повинні.

Замість самостійного написання коду синхронізації, можна використовувати Redux React Біндінг з пакета react-redux :

npm install --save react-redux

react-redux підключає наші чисті компоненти до Redux store за допомогою:

  • Відображення стану з store у вхідні властивості компонента.
  • Відображення actions в властивості callback-ів компонента.

Але поперше нам потрібно обернути наш головний компонент в компонент-провайдер ( Provider ) з react-redux. Він з’єднає наше дерево компонентів з Redux Store, що дозволить нам пізніше зв’язати store c окремими компонентами.

Помістимо провайдера навколо компонента-роутера. В результаті провайдер стане спадкоємцем усіх компонентів нашого застосування.

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

});

Тепер треба подумати про те, які з компонентів потрібно «підключити», щоб з store надходили всі необхідні дані. У нас є п’ять компонентів, які можна розділити на три категорії:

  • Кореневий компонент Appні в чому не потребує, тому що не використовує дані.
  • Voteі Winnerвикористовуються тільки батьківськими компонентами, що зраджують їм всі необхідні властивості. Вони теж не потребують підключення.
  • Залишаються тільки ті компоненти, які використовувалися при завданні шляхів: Votingі Results. Зараз вони отримують від Appзаглушки властивостей. Ось їх-то і треба підключити до store.

Почнемо з компонента Voting. Візьмемо з react-redux функцію connect , за допомогою якої будемо підключати компонент. Вона бере сопоставляющую функцію як аргумент, а повертає іншу функцію, приймаючу клас React-компонента:

connect(mapStateToProps)(SomeComponent);

Маппінг-функція здійснює зіставлення стану з Redux Store в якості об’єкта. Потім ці властивості будуть об’єднані з властивостями підключаємого компоненту. У випадку з Voting нам всього лише потрібно замапіть pairі winnerз Store:

src/components/Voting.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';

const Voting = React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    winner: state.get('winner')
  };
}

connect(mapStateToProps)(Voting);

export default Voting;

Це не зовсім вірно. З точки зору функціонального підходу, функція connect насправді не повинна змінювати компонент Voting. Він залишається чистим, не підключеним компонентом. Замість цього connect повертає підключену версію Voting . А це означає, що наш поточний код по суті нічого не робить. Візьмемо повертається значення і назвемо його VotingContainer:

src/components/Voting.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';

export const Voting = React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    winner: state.get('winner')
  };
}

export const VotingContainer = connect(mapStateToProps)(Voting);

Тепер модуль експортує чистий компонент Voting і підключений компонент VotingContainer. У документації react-redux перший називається «тупим» (dumb) компонентом, а другий – «розумним» (smart). Особисто я віддаю перевагу терміни «чистий» і «підключений». Називайте їх, як зручніше, але потрібно розуміти, чим вони відрізняються:

  • Чистий / тупий компонент повністю залежить від наданих йому властивостей. Являє собою еквівалент чистої функції, тільки компонент.
  • Підключений / розумний компонент обертає чисту версію в якусь логіку, яка дозволяє синхронізуватися із змінним станом з Redux store. Логіка береться з react-redux.

Оновимо нашу роутинг-таблицю, щоб замість Votingвикористовувався VotingContainer. Після цього екран голосування буде отримувати дані, які ми покладемо в Redux-сховище.

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Змінимо спосіб імпортування в модульному тесті для Voting, адже нам більше не потрібно використовувати Voting як експортера за замовчуванням:

test/components/Voting_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithTag,
  Simulate
} from 'react-addons-test-utils';
import {List} from 'immutable';
import {Voting} from '../../src/components/Voting';
import {expect} from 'chai';

Більше нічого змінювати не потрібно. Тести написані для чистого компонента Voting, який залишається незмінним. Ми просто додали обгортку, щоб підключити його до store.

Тепер те ж саме зробимо з екраном результатів, якому будемо передавати атрибути стану pair і winner. Крім того, для відображення результатів йому знадобиться і tally:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';

export const Results = React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
      </div>
      </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    tally: state.getIn(['vote', 'tally']),
    winner: state.get('winner')
  }
}

export const ResultsContainer = connect(mapStateToProps)(Results);

У index.jsxзмінимо шлях передачі результатів, замість Results буде ResultsContainer:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Нарешті, в тесті результатів оновимо вираз імпорту для Results:

test/components/Results_spec.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithClass,
  Simulate
} from 'react-addons-test-utils';
import {List, Map} from 'immutable';
import {Results} from '../../src/components/Results';
import {expect} from 'chai';

Таким ось чином можна підключати чисті React-компоненти до Redux-сховища, щоб вони могли отримувати звідти потрібні дані.

Для дуже маленьких додатків, що володіють єдиним кореневим компонентом і не використовують роутинг, в більшості випадків буде достатньо підключення кореневого компонента. А потім вже корінь делегує ці дані у вигляді властивостей для своїх дочірніх компонентів.

У додатках з роутингом, начебто ми створювали, звичайно краще підключати кожен з компонентів роутера. Але кожен компонент може бути підключений окремо, тому ви вільні застосовувати різні стратегії в залежності від архітектури додатку. На мій погляд, має сенс у всіх можливих випадках використовувати звичайні властивості, оскільки з ними простіше зрозуміти, які дані подаються на вхід. До того ж вам не доведеться розбиратися з кодом «підключення».

Отже, ми тепер можемо передавати в інтерфейс дані з Redux. У App.jsx нас більше не потрібні заглушки властивостей, так що код спрощується:

src/components/App.jsx
import React from 'react';

export default React.createClass({
  render: function() {
    return this.props.children;
  }
});

5.8. Налаштування клієнта Socket.io

Оскільки в наш клієнт є Redux-додаток, поговоримо про спосіб підключення до серверного Redux-додатку. На даний момент вони обидва існують в своїх власних світах, які не взаємодіючи один з одним.

Сервер вже готовий до прийняття вхідних socket-підключень і передачі їм стану голосування. А у клієнта є Redux-сховище, в яке можна легко записати вхідні дані. Залишилося тільки зв’язати їх.

Почнемо з інфраструктури. Нам потрібно створити Socket.io-канал від браузера до сервера. Для цього скористаємося бібліотекою socket.io-client , що є клієнтським аналогом бібліотеки, яку ми використовували на сервері:

npm install --save socket.io-client

Після імпорту бібліотеки ми отримали функцію io, яку можна використовувати для підключення до сервера Socket.io. Підключимося до порту 8090 (його ми використовували для сервера):

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const socket = io(`${location.protocol}//${location.hostname}:8090`);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Перевірте, що сервер запущений, відкрийте в браузері клієнтську програму і перегляньте мережевий трафік. Повинно бути встановлено WebSocket-підключення, в яке відправляються контрольні сигнали Socket.io.

Під час розробки ми будемо використовувати на сторінці два Socket.io-підключення: одне наше, а друге для підтримки гарячої Webpack-перезавантаження.

5.9. Отримання actions з сервера

Отримати вхідні дані з Socket.io каналу досить просто. При першому підключенні і кожній зміні сервер відправляє нам події state, досить їх просто слухати. Після отримання подібної події передаємо в наше сховище дію SET_STATE. Для його обробки там вже є reducer:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch({type: 'SET_STATE', state})
);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Зверніть увагу, що ми прибрали заглушечную передачу SET_STATE. Вона нам більше не потрібна, тому що сервер почав передавати реальний стан.

Погляньте на інтерфейс – голосування або результатів: там повинна відображатися перша пара записів, переданих з сервера. Підключення між клієнтом і сервером встановлено!

5.10. Передача actions від React-компонентів

Ми знаємо, як передавати в інтерфейс вхідні дані від Redux store. Давайте тепер поговоримо про передачу з інтерфейсу вихідних дій.

Найкраще почати з кнопок голосування. При створенні інтерфейсу ми прийняли, що компонент Voting буде отримувати властивість vote, значенням якого є callback-функція. Компонент викликає її, коли користувач клацає на кнопки. Але ми поки не забезпечили підтримку цієї функції, за винятком модульних тестів.

Що має статися, коли користувач голосує за якийсь запис? Очевидно, що його голос повинен бути відправлений на сервер. Детальніше ми поговоримо про це нижче, але там теж задіюється клієнтська логіка: компоненту має призначатися властивістьhasVoted, щоб користувач не міг двічі голосувати в рамках будь-якої пари.

Крім SET_STATEу нас буде другий клієнтський Redux action – VOTE. Він буде додавати запис hasVotedв стан:

test/reducer_spec.js
it('обрабатывает VOTE с помощью назначения hasVoted', () => {
  const state = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  });
  const action = {type: 'VOTE', entry: 'Trainspotting'};
  const nextState = reducer(state, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    },
    hasVoted: 'Trainspotting'
  }));
});

Також має сенс не брати це властивість в стан, якщо дія VOTE з якоїсь причини приходить із записом, яка в даний момент не бере участі в голосуванні:

test/reducer_spec.js
it('в случае неправильной записи не назначает hasVoted для VOTE', () => {
  const state = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  });
  const action = {type: 'VOTE', entry: 'Sunshine'};
  const nextState = reducer(state, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

Розширимо логіку reducer-a для обробки цього випадку:

src/reducer.js
import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

function vote(state, entry) {
  const currentPair = state.getIn(['vote', 'pair']);
  if (currentPair && currentPair.includes(entry)) {
    return state.set('hasVoted', entry);
  } else {
    return state;
  }
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return setState(state, action.state);
  case 'VOTE':
    return vote(state, action.entry);
  }
  return state;
}

Властивість hasVoted не повинна вічно залишатися в стані. При переході до наступної пари вона повинна перепризначатися, щоб користувач міг голосувати далі. Ми будемо обробляти цю логіку в SET_STATE, перевіряючи, чи міститься в парі голосування поточного стану запис, за яку користувач вже проголосував. Якщо не міститься, то ми видаляємо властивість hasVoted:

test/reducer_spec.js
it('если пара изменилась, то очищает hasVoted в SET_STATE', () => {
  const initialState = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    },
    hasVoted: 'Trainspotting'
  });
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Sunshine', 'Slumdog Millionaire']
      }
    }
  };
  const nextState = reducer(initialState, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Sunshine', 'Slumdog Millionaire']
    }
  }));
});

Це можна реалізувати поєднанням функції resetVote і обробника дії SET_STATE:

src/reducer.js
import {List, Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

function vote(state, entry) {
  const currentPair = state.getIn(['vote', 'pair']);
  if (currentPair && currentPair.includes(entry)) {
    return state.set('hasVoted', entry);
  } else {
    return state;
  }
}

function resetVote(state) {
  const hasVoted = state.get('hasVoted');
  const currentPair = state.getIn(['vote', 'pair'], List());
  if (hasVoted && !currentPair.includes(hasVoted)) {
    return state.remove('hasVoted');
  } else {
    return state;
  }
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return resetVote(setState(state, action.state));
  case 'VOTE':
    return vote(state, action.entry);
  }
  return state;
}

Ця логіка визначення актуальності властивості hasVoted для поточної пари має вади. Зверніть увагу на вправи нижче для поліпшення логіки.

Прийшла пора підключити властивість hasVoted до властивостей Voting:

src/components/Voting.jsx
function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    hasVoted: state.get('hasVoted'),
    winner: state.get('winner')
  };
}

Нам ще потрібно якось передати в Voting vote callback, який приведе до обробки цієї нової дії. Votingповинен залишатися чистим і незалежним від actions або Redux, тому задіємо функцію connect з react-redux.

react-redux можна використовувати для підключення як вхідних властивостей, так і вихідних дій . Але спочатку ми задіємо ще одну ключову ідею Redux: творці дій (Action creators) .

Як ми вже знаємо, дії в Redux є простими об’єкти, що володіють (за згодою) атрибутом type і іншими специфічними даними. Ми створювали ці дії в міру потреби за допомогою об’єктних литералов. Але краще використовувати маленькі фабричні функції на зразок цієї:

function vote(entry) {
  return {type: 'VOTE', entry};
}

Такі функції ще називають «творцями дій». Вони є чистими функціями, які всього лише повертають об’єкти дій. Але при цьому таким чином інкапсулюють внутрішню структуру об’єктів дій, щоб вона більше не була ніяк пов’язана з іншою кодовою базою. За допомогою творців дій також зручно документувати всі дії, які можуть бути передані в вашу програму. Було б важче збирати подібну інформацію, будь вона розкидана по всій кодової базі у вигляді об’єктних литералов.

Створимо новий файл, який визначає творців дій для двох наших вже існуючих клієнтських дії:

src/action_creators.js
export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    type: 'VOTE',
    entry
  };
}

Для цих функцій дуже легко писати модульні тести. Але зазвичай я цього не роблю, якщо творець дії тільки повертає об’єкт. Але якщо хочете, то можете додати.

Тепер у файлі index.jsx в Socket.io-обробнику події ми можемо використовувати творця дії setState:

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import {setState} from './action_creators';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Ще одна перевага творців дій полягає тому, як react-redux підключає їх до React-компонентів. У нас є callback-властивість vote в Voting і творець дії vote. Імена однакові, як і сигнатури функції: один аргумент, який є записом, за яку проголосували. Так що ми можемо просто передати творця дії в функцію connect з react-redux як другий аргумент:

src/components/Voting.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';
import * as actionCreators from '../action_creators';

export const Voting = React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    hasVoted: state.get('hasVoted'),
    winner: state.get('winner')
  };
}

export const VotingContainer = connect(
  mapStateToProps,
  actionCreators
)(Voting);

В результаті властивість vote буде передано в Voting. Це властивість являє собою функцію, яка створює дію за допомогою творця vote, і відправляє цю дію в Redux Store. Тобто дія буде відправлятися при кліці на кнопку голосування! Можете відразу перевірити це в браузері: після натискання на кнопку вона деактивується.

5.11. Відправка дій на сервер за допомогою Redux Middleware

Тепер нам потрібно вирішити останнє питання – отримання сервером результатів призначених для користувача дій. Це повинно відбуватися при голосуванні і натисканні на кнопку “Next” на екрані результатів.

Почнемо з голосування. Що у нас вже є?

  • Коли користувач голосує, створюється дію VOTE і відправляється в клієнтський Redux store.
  • Дії VOTE обробляються клієнтським reducer-му у вигляді призначення властивості hasVoted.
  • Сервер готовий до прийняття actions від клієнтів за допомогою Socket.io-подій action. Всі отримані дії будуть передаватися в серверний Redux Store.
  • Дії VOTE обробляються серверним reducer-му реєструванням голосу і оновленням результатів.

Схоже, у нас є майже все, що потрібно. Бракує тільки відправки на сервер клієнтських дій VOTE, щоб обробити їх в обох Redux stores. Цим ми і займемося.

З чого почати? У Redux немає нічого підходящого для цього, оскільки в число його основних завдань не входить підтримка розподілених систем на зразок нашої. Так що будемо самі вирішувати, як нам організувати відправку дій на сервер.

Redux надає шаблонний спосіб подцепленний до actions, що відправляється в redux store – Middleware .

Middleware (посередник) – це функція, яка викликається при передачі дії ще до того, як це дію потрапить в reducer і store. Middleware можна використовувати в різних цілях, від логгіруванія і обробки винятків до модифікування дій, кешування результатів і зміни способу та часу попадання дії в store. Ми ж скористаємося цими функціями для відправки клієнтських actions на сервер.

Зверніть увагу на різницю між middleware і listeners:

  • Перші викликаються до того, як дія потрапляє в store, тому вони можуть вплинути на нього.
  • Другі викликаються після виконання дії, і вже ніяк не можуть вплинути на його долю.

Різні інструменти для різних завдань.

Створимо remote action middleware, завдяки якому за допомогою Socket.io-підключення дію буде відправлено не тільки в початковий store, але і в віддалений.

Налаштуємо каркас нашого middleware. Це функція, яка бере Redux store і повертає іншу функцію, приймаючу callback «next». Ця інша функція повертає третю , приймаючу Redux action. Саме ця остання функція і відображає реалізацію middleware:

src/remote_action_middleware.js
export default store => next => action => {

}

Ви можете порахувати попередній код трохи дивним, але це більш конкретний спосіб запису:

export default function(store) {
  return function(next) {
    return function(action) {

    }
  }
}

Подібний спосіб вкладання одноаргументних функцій називається каррінг . У цьому випадку ми можемо легко конфігурувати посередника: якби всі аргументи містилися в одній функції ( function(store, next, action) { }), то нам довелося б передавати їх при кожному використанні посередника. А завдяки каррінг ми можемо раз викликати першу функцію і отримати значення, що повертається, яке «пам’ятає», який store потрібно використовувати.

Те ж саме відноситься і до аргументу next. Це callback, який повинен викликатися middleware по завершенні роботи, коли потрібно передати action в store (або наступного middleware):

src/remote_action_middleware.js
export default store => next => action => {
  return next(action);
}

Посередник може вирішити не викликати next, якщо вважатиме за потрібне затримати дію. Тоді воно вже ніколи не потрапить в reducer або store.

Давайте щось залоггіруем в middleware, щоб дізнатися, коли воно викликається:

src/remote_action_middleware.js
export default store => next => action => {
  console.log('in middleware', action);
  return next(action);
}

Якщо додати це middleware до нашого Redux store, то всі дії будуть залогуватися. Активувати middleware можна за допомогою Redux функції applyMiddleware. Вона бере middleware, яке ми хочемо зареєструвати, і повертає функцію, яка, в свою чергу, бере функцію createStore. Потім ця друга функція створює для нас store з уже увімкненим в нього middleware:

src/components/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import {setState} from './action_creators';
import remoteActionMiddleware from './remote_action_middleware';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const createStoreWithMiddleware = applyMiddleware(
  remoteActionMiddleware
)(createStore);
const store = createStoreWithMiddleware(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Це ще один хороший приклад використання каррінг. І його дуже активно використовують API Redux.

Тепер, якщо ми перезавантажити додаток, то побачимо, що middleware логгіруе все що відбуваються actions: спочатку вихідне SET_STATE, а в ході голосування – VOTE.

Middleware має відправляти отримане дію в Socket.io-підключення та передавати наступному middleware. Але для початку потрібно надати йому це підключення. Воно вже є у нас в index.jsx, залишилося тільки дати middleware доступ. Це легко здійснити за допомогою ще одного каррінг у визначенні middleware. Зовнішня функція повинна брати сокет Socket.io:

src/remote_action_middleware.js
export default socket => store => next => action => {
  console.log('in middleware', action);
  return next(action);
}

 

src/index.jsx
const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const createStoreWithMiddleware = applyMiddleware(
  remoteActionMiddleware(socket)
)(createStore);
const store = createStoreWithMiddleware(reducer);

Зверніть увагу, що нам потрібно поміняти місцями ініціалізацію сокета і store: сокет повинен створюватися першим, оскільки він нам знадобиться в ході ініціалізації store.

Залишилося тільки, щоб middleware згенерувало подію action:

src/remote_action_middleware.js
export default socket => store => next => action => {
  socket.emit('action', action);
  return next(action);
}

От і все! Тепер при кліці на одну з кнопок голосування ви побачите в тому ж вікні браузера оновлення поточних результатів. Те ж саме відбудеться і в інших браузерах, в яких буде запущено програму. Голос зареєстрований!

Але тут є одна проблема: коли ми отримуємо з сервера оновлення стану і передаємо дію SET_STATE, воно також потрапляє на сервер. І хоча він нічого не робить з цією дією, але все ж його одержувач запитів спрацьовує, генеруючи нове SET_STATE. Виходить нескінченний цикл.

Middleware віддаленого action не повинен відправляти на сервер кожна дія. Деякі з них, на зразок SET_STATE, повинні оброблятися локально, на клієнті. Нехай посередник відправляє на сервер тільки конкретні дії, що містять властивість {meta: {remote: true}}:

(цей підхід взято з прикладів rafScheduler з документації middleware )

src/remote_action_middleware.js
export default socket => store => next => action => {
  if (action.meta && action.meta.remote) {
    socket.emit('action', action);
  }
  return next(action);
}

Творець дії повинен призначати це властивість для VOTE, і не повинен для SET_STATE:

src/action_creators.js
export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    meta: {remote: true},
    type: 'VOTE',
    entry
  };
}

Резюмуємо, що відбувається:

  1. Користувач клацає кнопку голосування. Передається дію VOTE.
  2. Middleware віддаленої дії відправляє action через Socket.io-підключення.
  3. Дія обробляється клієнтським Redux store, в результаті чого призначається локальне стан hasVote.
  4. Коли повідомлення приходить на сервер, то серверний Redux store обробляє action і оновлює голос в поточних результатах.
  5. Одержувач запитів в серверному store транслює снепшот стану всім підключеним клієнтам.
  6. У Redux store кожного з підключених клієнтів передаються дії SET_STATE.
  7. Вони обробляються сховищами на основі оновленого сервером стану.

Для завершення нашої програми залишилося тільки змусити працювати кнопку “Next”. На сервері вже є необхідна логіка, як і в модулі голосування. Залишилося тільки з’єднати їх один з одним.

Творець дії для NEXT повинен створювати віддалене дію правильного типу:

src/action_creator.js
export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    meta: {remote: true},
    type: 'VOTE',
    entry
  };
}

export function next() {
  return {
    meta: {remote: true},
    type: 'NEXT'
  };
}

Творці дій підключаються у вигляді властивостей до компоненту ResultsContainer:

src/components/Results.jsx
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import * as actionCreators from '../action_creators';

export const Results = React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
        </div>
      </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    tally: state.getIn(['vote', 'tally']),
    winner: state.get('winner')
  }
}

export const ResultsContainer = connect(
  mapStateToProps,
  actionCreators
)(Results);

Ну от і все! Тепер наше додаток повністю готовий і функціонує. Спробуйте відкрити екран результатів на комп’ютері та екран голосування на мобільному пристрої. Ви побачите, що після виконання дії на одному пристрої результат відразу відображається на іншому. Чарівне видовище. Натисніть на кнопку «Next» на екрані результатів і подивіться, як просувається голосування на іншому пристрої.

Переклад статті Руководство по работе с Redux

Арбитраж трафика 2020

Можно ли войти в арбитраж трафика в 2020 году? Я думаю что да. Вернее не так. Я уверен что да.

Именно этим я сейчас и занимаюсь. Как оказалось в интернете крутится очень мого денег, и мне стало очень обидно что все эти потоки денег идут мимо меня. Поэтому я и решил заняться изучением арбитража трафика. Но хочу сразу сказать что тут нет магической кнопки “Бабло”. Все нужно изучать анализировать и тестировать. Во все это нужно вкладывать средства. И тут есть 2 варианта. Первое это ты потратишь деньги и купишь опыт полученный на собственных ошибках, второй вариант это ты заработаешь на этом денег. И естественно чем больше опыта ты купишь в соответствии с пунктом 1 тем больше денег ты заработаешь в соответствии с пунктом 2.

Вся реклама курсов которые научат за неделю, зарабатывать на арбитраже 1000$ + в день это полная ерунда. Ни одни курсы ни один арбитражник даже за деньги не поделится своим опытом до конца на все 100% только лишь потому что это ему личо не выгодно. Зачем создавать себе прямых конкурентов.

Учится нужно постоянно и каждый день. А арбитраже нет такого, что закончил курсы и сиди себе работай. Тут очень хорошо работает поговорка “Век живи – век учись!”. Вот я и решил учится и пытаться. 🙂

Вся суть арбитража трфика состоит в том, чтобы купть трафик у одних по дешевле и продать другим но по дороже. 🙂 Вроде на словах все просто, а на деле оказывается что не очень.

Первое и очень важно это нужно найти покупателей трафика. На прямую с покупателями трафика работают только крутые арбитражники с очень большими оборотами либо площадки которые являются прокладками между арбитражником и покупателем трафика. Я пробовал несколько таких площадок, остановился на advertise (не хочу никого рекламировать или писать о ком то гадости) просто так сложилось что остановился именно на ней, пожет из за того что фаза луны была такая 🙂 может потому что у них порог выплат не высокий хз. Если есть желание можете ее попробовать для начала или поискать какую то другую.

Потом нужно выбрать оффер для себя, на который вы будете лить трафик. Оферы бывают разные, на разные тематики и на разные действия. Проще всего, для старта, подобрать оффер где оплата не за покупку а за регистрацию. Почитать все условия по трафику, изучить правила оффера, и посчитать какая максимальная цена должна быть у трафика который вы будете покупать чтобы заработать на этом денег. На каждом офере указывается CR это процент совершивших нужное действие от зашедших по вашей ссылке на оффер. Соответственно чем выше CR тем выгоднее. Так же в каждом оффере указывается сумма которая будет выплачиваться за каждое действие. Определить максимальную цену за клик по вашей ссылке можно путем взятия процента (CR) от выплаты за действие. Это и будет максимальная цена вашего трафика за клик при которой вы выйдете в ноль. Соответсвтенно нужно покупать трафик ниже этой цены и все.

Вроде все просто. Но не совсем 🙂

Дальше нужно найти площадку где покупать трафик. Площадок очень много и самых разных начиная от тизерных сетей и заканчивая goole и facebook. На каджой площадке существуют свои правила и свои условия модерации рекламных объявлений. Чем круче прощадка тем сложнее пройти модерацию.

Проще начинать с тизерных сетей, их очень много и на них проще всего пройти модерацию банеров и объявлений. В каждой тизерной сети есть минимальная сумма пополнения. Я взял для себя павило, заводить на новую для меня прощадку только минимальную сумму, проводить тесты и смотреть на качетсво трафика. Например у visitweb минимальная сумма пополнения 5$, у teasernet минимальна сумма пополнения 5$, у plugrush минимальна сумма пополнения 25$. У каждой тизерки есть свои настройки компаний и объявлений в поответствии с офером (тексты, банеры, языки, страны, цены за клик). Все площадки имеют свою тематику для объявлений. Поэтому нужно подходить ко всему индивидуально.

Вроде как все просто. Но есть много своих ньюансов которые узнаешь в процессе работы с каждой площадкой и с каждым оффером.

Дерзайте и у вас все получится!

С прикупом новых знаний буду писать новые статьи.

🙂

Unit-тести для Angular

Unit-тести для Angular

Unit-тестування – це важлива частина процесу розробки на поточний момент і вона стає невід’ємною частиною. Це підвищує якість коду і впевненість розробників. У даній статті розповідається про те, як створювати unit-тести для Angular, що використовувати і чому.

Для написання unit-тестів використовується фреймворк Jasmine, а виконуються тести в Karma. Karma – це JavaScript движок для запуску тестів, які виконуються у браузері.

Є дві основні концепції тестування TDD і BDD:

  • TDD (test driven development) – розробка на основі тестів. Спочатку пишемо тести, а потім під них пишемо код. Концепція орієнтована на перевірку кожного блоку коду, написаного розробником. Саме для цього і використовуються unit-тести.
  • BDD (behavior driven development) – розробка на основі поведінки. Це відгалуження від TDD. Концепція орієнтована на перевірку бізнес-сценаріїв, реалізованих в коді.

Фреймворк Jasmine прекрасно підходить і для TDD, і для BDD.

Особливості unit-тестів

  • Кожен тест повинен виконуватися дуже швидко
  • Кожен тест повинен бути ізольований від всіх залежностей
  • Тест повинен мати чітко визначений результат виконання для кожного набору вхідних даних
  • Найменший блок коду (unit) може бути перевірений окремим тестом

Коли ми пишемо unit-тести

  • На самому початку або в кінці розробки
  • Якщо ми додали нову фічу в коді, ми повинні покрити її роботу тестом
  • Якщо ми змінили існуючу фичу в коді, ми також повинні змінити / додати для неї тест

Чому варто використовувати Jasmine

  • За замовчуванням інтегрована з Karma
  • Надає зручний функціонал, наприклад, Spy, fake
  • Легко інтегрується зі звітністю

Чому варто використовувати Karma

  • Angular CLI вміє працювати з Karma з коробки.
  • Дозволяє автоматизувати тести для різних браузерів і девайсів.
  • Стежить за змінами файлів без ручного перезапуску
  • Добре задокументована
  • Легко інтегрується з CI-серверами

Приклад проекту

Для запуску тестування на Angular не потрібно нічого налаштовувати. Завдяки Angular CLI всі налаштування (Jasmine і Karma) створюються автоматично.

// створюємо новий Angular проект 
ng new angular-unit-testing
// запускаємо тестування 
npm test

І ви відразу ж побачите результат в браузері:

Результат тестування

Для тестування різних особливостей Angular був створенний приклад проекту. Це простий to-do список. Нижче скріншот і посилання на проект.

Приклад проекту

Конфігурація тестів в Angular

Коли ми запускаємо npm test або ng test, Angular використовує настройки з файлу angular.json. Зверніть увагу на test-параметр цього файлу.
Ми вказали test.ts як основний файл для запуску і karma.conf.js як конфігурацію для Karma.

//angular.json
" Test " : {
          " Builder " : " @ angular-devkit / build-angular: karma " ,
          " Options " : {
            " Main " : " src / test.ts " ,
            " Polyfills " : " src / polyfills.ts " ,
            " TsConfig " : " src / tsconfig.spec.json " ,
            " KarmaConfig " : " src / karma.conf.js " ,
            " Styles " : [
              " Src / styles.css "
            ],
            " Scripts " : [],
            " Assets " : [
              " Src / favicon.ico " ,
              " Src / assets "
            ]
          }
        },

У файлі test.ts ми завантажуємо всі файли проекту з розширенням .spec.ts для тестування.

//test.ts
// Then we find all the tests.
const  context  =  require . context ( './' ,  true ,  / \. spec \. ts $ / ) ;
// And load the modules.
context . keys ( ) . map ( context ) ;

А в файлі karma.conf.js ми визначаємо настройки звітів, порт, типи браузерів для тестування і тп.

//karma.conf.js
module . exports  =  function  ( config )  {
  config . set ( {
    basePath : '' ,
    frameworks : [ 'jasmine' ,  '@ angular-devkit / build-angular' ] ,
    plugins : [
      require ( 'karma-jasmine' ) ,
      require ( 'karma-chrome-launcher' ) ,
      require ( 'karma-jasmine-html-reporter' ) ,
      require ( 'karma-coverage-istanbul-reporter' ) ,
      require ( '@ angular-devkit / build-angular / plugins / karma' )
    ] ,
    client : {
      clearContext : false  // leave Jasmine Spec Runner output visible in browser
    } ,
    coverageIstanbulReporter : {
      dir : require ( 'path' ) . join ( __dirname ,  '../coverage/todoApp' ) ,
      reports : [ 'html' ,  'lcovonly' ,  'text-summary' ] ,
      fixWebpackSourcePaths : true
    } ,
    reporters : [ 'progress' ,  'kjhtml' ] ,
    port : 9876 ,
    colors : true ,
    logLevel : config . LOG_INFO ,
    autoWatch : true ,
    browsers : [ 'Chrome' ] ,
    singleRun : false ,
    restartOnFileChange : true
  } ) ;
} ;

Основні функції

TestBed

Це базовий блок для створення тестового модуля. TestBed створює динамічний тестовий модуль, який емулює роботу NgModule. Вхідні дані для TestBed.configureTestingModule() і @NgModule абсолютно однакові. І саме TestBed.configureTestingModule() є конфігурацією тестового файлу.

//sample.spec.ts
describe ( 'AppComponent' ,  ( )  =>  {
  let  appComponent : AppComponent ;

  beforeEach ( async ( ( )  =>  {
    TestBed . configureTestingModule ( {
      imports : [
        RouterTestingModule
      ] ,
      declarations : [
        AppComponent
      ] ,
      schemas : [  NO_ERRORS_SCHEMA  ]
    } ) . compileComponents ( ) ;
  } ) ) ;
} ) ;

Конфігурація завжди розміщується у beforeEach() функції, яка запускається перед кожним тестом.

compileComponents()

У цьому ж файлі ми використовуємо compileComponents() метод. Якщо ви створюєте компонент, який використовує зовнішні файли для визначення styleUrls, templateUrl, тоді ви повинні отримати ці файли з файлової системи до того, як компонент буде створений. Це асинхронна операція і тому ми обертаємо конфігурацію в асинхронну функцію.

Фактично відбувається наступне: при запуску тестування підтягується зовнішній файл, а потім починається компіляція компонента. Якщо ви запускаєте тести з Angular CLI, тоді асинхронна функція не потрібна. Оскільки CLI компілює весь код до запуску тестів.

Чому дві beforeEach() функції

Якщо ви подивіться на файл нижче, то побачите, що ми використовуємо дві beforeEach() функції. Оскільки compileComponents() запускається асинхронно, ми хочемо бути впевнені, що компонент буде скомпільовано до того, як ми створимо fixture (надає доступ до примірника компонента). Друга beforeEach() запускається після першої.

//app.component.spec.ts
describe ( 'AppComponent' ,  ( )  =>  {

  let  appComponent : AppComponent ;

  beforeEach ( async ( ( )  =>  {
    TestBed . configureTestingModule ( {
      imports : [
        RouterTestingModule
      ] ,
      declarations : [
        AppComponent
      ] ,
      schemas : [  NO_ERRORS_SCHEMA  ]
    } ) . compileComponents ( ) ;
  } ) ) ;

  beforeEach ( ( )  =>  {

    const  fixture  =  TestBed . createComponent ( AppComponent ) ;
    appComponent  =  fixture . debugElement . componentInstance ;
    appComponent . itemList  =  [ { id : 1 ,  name : 'todolist' ,  description : 'This is todo list app' } ,
                      { Id : 2 ,  name : 'unit test' ,  description : 'Write unit tests' } ] ;
  } ) ;

  it ( 'should create the app' ,  ( )  =>  {
    expect ( appComponent ) . toBeTruthy ( ) ;
  } ) ;
} ) ;

Навіщо використовувати NO_ERRORS_SCHEMA

Давайте виберемо app.component.ts компонент з проекту і протестуємо його. Подивіться на spec файл, який Angular CLI згенерував для нас, коли ми виконали команду

ng nc

Була додана f (рядок 5) до функції describe(), щоб виконувати тільки цей файл під час тестування. Це називається Focused Tests.

//app.component.spec.ts
import  {  TestBed ,  async  }  from  '@ angular / core / testing' ;
import  {  RouterTestingModule  }  from  '@ angular / router / testing' ;
import  {  AppComponent  }  from  './app.component' ;

fdescribe ( 'AppComponent' ,  ( )  =>  {
  beforeEach ( async ( ( )  =>  {
    TestBed . configureTestingModule ( {
      imports : [
        RouterTestingModule
      ] ,
      declarations : [
        AppComponent
      ] ,
    } ) . compileComponents ( ) ;
  } ) ) ;

  it ( 'should create the app' ,  ( )  =>  {
    const  fixture  =  TestBed . createComponent ( AppComponent ) ;
    const  app  =  fixture . debugElement . componentInstance ;
    expect ( app ) . toBeTruthy ( ) ;
  } ) ;

} ) ;

Запустивши npm test, ми побачимо багато помилок через вкладеності компонентів, тому що основний компонент містить інші компоненти, такі як header, footer. Ми повинні оголосити їх в app.component.spec.ts в масиві declarations (рядок 11).

Тестування

Інший спосіб – використання NO_ERRORS_SCHEMA. Ця установка дозволяє ангуляр ігнорувати всі невідомі теги при тестуванні компонента.

NO_ERRORS_SCHEMA

Tестування компонентів

У нашому компоненті є метод deleteItem(). Давайте його протестуємо.

//app.component.spec.ts 
import  {  TestBed ,  async  }  from  '@ angular / core / testing' ;
import  {  RouterTestingModule  }  from  '@ angular / router / testing' ;
import  {  AppComponent  }  from  './app.component' ;
import  {  NO_ERRORS_SCHEMA  }  from  '@ angular / core' ;

fdescribe ( 'AppComponent' ,  ( )  =>  {

  let  appComponent : AppComponent ;

  beforeEach ( async ( ( )  =>  {
    TestBed . configureTestingModule ( {
      imports : [
        RouterTestingModule
      ] ,
      declarations : [
        AppComponent
      ] ,
      schemas : [  NO_ERRORS_SCHEMA  ]
    } ) . compileComponents ( ) ;
  } ) ) ;

  beforeEach ( ( )  =>  {

    const  fixture  =  TestBed . createComponent ( AppComponent ) ;
    appComponent  =  fixture . debugElement . componentInstance ;
    appComponent . itemList  =  [ { id : 1 ,  name : 'todolist' ,  description : 'This is todo list app' } ,
                      { Id : 2 ,  name : 'unit test' ,  description : 'Write unit tests' } ] ;
  } ) ;

  it ( 'should create the app' ,  ( )  =>  {
    expect ( appComponent ) . toBeTruthy ( ) ;
  } ) ;

  it ( 'should delete an item with existing item' ,  ( )  =>  {

    // given this item
    const  item  =  { id : 1 ,  name : 'todolist' ,  description : 'This is todo list app' } ;
    // execute the test case
    appComponent . deleteItem ( item ) ;
    // assertion
    expect ( appComponent . itemList . length ) . toBe ( 1 ) ;
  } ) ;

  it ( 'should not delete an item with new item' ,  ( )  =>  {

    // given this item
    const  item  =  { id : 3 ,  name : 'todolist' ,  description : 'This is todo list app' } ;
    // execute the test case
    appComponent . deleteItem ( item ) ;
    // assertion
    expect ( appComponent . itemList . length ) . toBe ( 2 ) ;
  } ) ;


} ) ;
//app.component.ts 
deleteItem ( item : any ) {
    console . log ( item ) ;
    this . itemList  =  this . itemList . filter ( ( itm : any )  =>  itm . id ! == item . id ) ;
}

Тестування DOM

Давайте протестуємо footer компонент, в якому у нас є footerText і numberOfItems Змінимо їх в компоненті і перевіримо шаблон footer.

Тестування DOM
//footer.component.spec.ts
import  {  async ,  ComponentFixture ,  TestBed  }  from  '@ angular / core / testing' ;
import  {  DebugElement  }  from  '@ angular / core' ;
import  {  By  }  from  '@ angular / platform-browser' ;

import  {  FooterComponent  }  from  './footer.component' ;

fdescribe ( 'FooterComponent' ,  ( )  =>  {
  let  component : FooterComponent ;
  let  fixture : ComponentFixture < FooterComponent > ;

  beforeEach ( async ( ( )  =>  {
    TestBed . configureTestingModule ( {
      declarations : [  FooterComponent  ]
    } )
    . compileComponents ( ) ;
  } ) ) ;

  beforeEach ( ( )  =>  {
    fixture  =  TestBed . createComponent ( FooterComponent ) ;
    component  =  fixture . componentInstance ;
    fixture . detectChanges ( ) ;
  } ) ;

  it ( 'should create' ,  ( )  =>  {
    expect ( component ) . toBeTruthy ( ) ;
  } ) ;

  it ( 'Shoud display footerText as Unit Testing' ,  ( )  =>  {

    component . footerText  =  'Unit Testing' ;
    fixture . detectChanges ( ) ;

    const  bannerDe : DebugElement  =  fixture . debugElement ;
    const  headingDe  =  bannerDe . query ( By . css ( 'h2' ) ) ;
    const  h2 : HTMLElement  =  headingDe . nativeElement ;

    expect ( h2 . textContent ) . toEqual ( 'Unit Testing' ) ;

  } ) ;

  it ( 'Shoud display numberOfItems as 10' ,  ( )  =>  {

    component . numberOfItems  =  10 ;
    fixture . detectChanges ( ) ;

    const  bannerDe : DebugElement  =  fixture . debugElement ;
    const  headingDe  =  bannerDe . query ( By . css ( 'h1' ) ) ;
    const  h1 : HTMLElement  =  headingDe . nativeElement ;

    expect ( h1 . textContent ) . toContain ( '10' ) ;

  } ) ;

} ) ;

Зверніть увагу на fixture.detectChanges(). Ми змінили footerText і numberOfItems в тесті і викликали механізм виявлення змін ангуляр для того, щоб DOM був оновлений відповідно до змін до запуску тесту.

Нижче результат тесту в браузері. Оскільки footer є автономним компонентом, ми бачимо результат рендеринга в Karma браузері.

Тестування

Тестування pipe

Тестувати Angular pipe дуже просто, оскільки вони не мають залежностей. Є pipe, який показує поточний рік після тексту в footer. Давайте його протестуємо:

//copyright.pipe.spec.ts
import  {  CopyrightPipe  }  from  './copyright.pipe' ;

fdescribe ( 'CopyrightPipe' ,  ( )  =>  {

  it ( 'create an instance' ,  ( )  =>  {
    const  pipe  =  new  CopyrightPipe ( ) ;
    expect ( pipe ) . toBeTruthy ( ) ;
  } ) ;

  it ( 'should display current year at the end' ,  ( )  =>  {
    const  pipe  =  new  CopyrightPipe ( ) ;

    const  result  =  pipe . transform ( 'Pipe Testing' ) ;

    expect ( result ) . toBe ( 'Pipe Testing'  +  new  Date ( ) . getFullYear ( ) ) ;
  } ) ;
} ) ;

Тестування сервісів

Сервіси теж досить просто тестувати. У нас є один сервіс в додатку. Давайте протестуємо метод addItem() в сервісі:

//app.service.spec.ts 
import  {  TestBed  }  from  '@ angular / core / testing' ;

import  {  AppService  }  from  './app.service' ;

fdescribe ( 'AppService' ,  ( )  =>  {
  beforeEach ( ( )  =>  TestBed . configureTestingModule ( { } ) ) ;
  let  service : AppService ;

  beforeEach ( ( )  =>  {
    service  =  TestBed . get ( AppService ) ;
    service . totalItems  =  [ { id : 1 ,  name : 'todolist' ,  description : 'This is todo list app' } ] ;
  } ) ;

  it ( 'should be created' ,  ( )  =>  {
    expect ( service ) . toBeTruthy ( ) ;
  } ) ;

  it ( 'should add item to total items' ,  ( )  =>  {

    const  item  =  { id : 2 ,  name : 'seconditem' ,  description : 'This is second item' } ;

    service . addItems ( item ) ;

    expect ( service . totalItems . length ) . toBe ( 2 ) ;
  } ) ;


} ) ;

Покриття коду тестами

Покриття коду показує як багато відсотків коду покрито unit-тестами. Зазвичай хорошим показником є ​​80% покриття.

Angular CLI дозволяє згенерувати звіт про покриття додатки тестами в окремій директорії, званої coverage. Щоб згенерувати звіт потрібно запустити тести з прапором – code-coverage.

ng test - no-watch - code-coverage
Покриття коду тестами

Якщо ви запустите index.html з цієї папки, то побачите повний звіт.

Покриття коду тестами

Практичні поради

  • Переконайтеся, що тест запускається в ізольованому оточенні, без зовнішніх залежностей. Тоді він буде виконуватися швидко.
  • Переконайтеся, що ваш додаток покрито як мінімум на 80% коду тестами.
  • Коли тестируете сервіси, завжди використовуйте spy з Jasmine фреймворка для залежностей. Це дозволить виконувати тести набагато швидше.
  • Коли ви підписуєтеся на Observable під час тесту, переконайтеся, що ви обробляєте і success, і failure результат. Це позбавить вас від непотрібних помилок.
  • Коли тестируете компонент з сервіс-залежностями, завжди використовуйте mock сервіс замість реального сервісу.
  • Усі зміни TestBedConfiguration повинні бути в beforeEach, щоб не дублювати однаковий код для кожного тесту.
  • Коли тестируете компоненти, завжди звертайтеся до DOM через debugElement замість nativeElement. Тому що debugElement надає абстрактний шар для середовища виконання. Це зменшує кількість помилок.
  • Використовуйте By.css замість queryselector, коли запускаєте тестування на сервері. Справа в тому, що queryselector працює тільки в браузері. Якщо ви запустите тестування на сервері, то воно впаде.
  • Завжди використовуйте fixture.detectChanges() замість ComponentFixtureAutoDetect. ComponentFixtureAutoDetect не знає про синхронний оновленні компонента.
  • Викликайте compileComponents(), якщо ви запускаєте тестування не в CLI середовищі.
  • Використовуйте RXJS marble тестування завжди, коли тестируете observable.
  • Використовуйте PageObject модель для перевикористання функцій між компонентами.
  • Не зловживайте NO_ERRORS_SCHEMA. Ця установка дозволяє ангуляр ігнорувати атрибути і нерозпізнані теги. Використовуйте заглушки компонентів.
  • Ніколи не виконуйте ніяких налаштувань після виклику compileComponents()compileComponents() є асинхронним і повинен виконуватися в async функції всередині beforeEach. Наступні настройки виконуйте в наступному синхронному beforeEach.

Висновок

Використовувати unit-тести в Angular додатку дуже зручно, якщо ви розумієте базові принципи тестування Angular, Jasmine і Karma.

Переклад статті Unit-тесты для приложения на Angular 2+

Тести в Angular

У Angular для тестів використовуються два інструменти:
Jasmine – фреймворк для написання тестів, і
Karma – інструмент для запуску цих тестів в браузері.

Jasmine

методи Jasmine

  • describe(description, function) – метод застосовується для угруповання взаємозалежних тестів
  • beforeEach(function) – метод застосовується для призначення завдання, яке повинно виконуватися перед кожним тестом
  • afterEach(function) – метод застосовується для призначення завдання, яке повинно виконуватися після кожного тестом
  • it(description, function) – метод застосовується для виконання тесту
  • expect(value) – метод застосовується для ідентифікації результату тесту
  • toBe(value) – метод застосовується для завдання очікуваного значення тесту: метод порівнює результат із значенням
  • toEqual(object) – перевіряє, що результатом є той самий об’єкт, що і заданий значення
  • toMatch(regexp) – перевіряє, що результат відповідає заданому регулярному виразу
  • toBeDefined() – перевіряє, що результат визначений
  • toBeUndefined() – перевіряє, що результат не визначений
  • toBeNull() – перевіряє, що результат дорівнює Null
  • toBeTruthy() – перевіряє, що результат є квазіістінним
  • toBeFalsy() – перевіряє, що результат є квазіложним
  • toContain(substring) – перевіряє, що результат містить задану підрядок
  • toBeLessThan(value) – перевіряє, що результат менше заданого значення
  • toBeGreaterThan(value) – перевіряє, що результат більше заданого значення

Клас TestBed і його методи

Клас TestBed– відповідає за моделювання середовища додатку Angular для виконання тестів.

  • TestBed.configureTestingModule – налаштовуємо тестовий модуль;
  • TestBed.createComponent – отримуємо екземпляр компонента;
  • compileComponents– застосовується для компіляції компонентів. compileComponents повертає Promise, який дозволяє налаштувати основні змінні для тесту після компіляції компонента (наприклад, коли ми використовуємо зовнішній шаблон і т.д.);
beforeEach( async(() => { 
    TestBed.configureTestingModule({ 
        declarations:[TestComponent]
    });
    TestBed.compileComponents().then(() => {
        fixture = TestBed.createComponent(TestComponent);
        component = fixture.componentInstance;
    });
}));

Зверніть увагу на метод async, який займається тим, що відстежує всі асинхронні завдання всередині нього, приховуючи від нас складність асинхронного виконання.

ComponentFixture

Результатом методу createComponent є об’єкт ComponentFixture, який надає властивості і методи для тестування компонента:

  • componentInstance – повертає об’єкт компонента;
  • nativeElement – повертає об’єкт DOM, який представляє керуючий елемент для компонента;
  • debugElement – повертає тестовий керуючий елемент для компонента;
  • detectChanges() – примушує тестову середу виявляти зміни стану і відображати їх в шаблоні компонента;
  • whenStable() – повертає об’єкт Promise, дозволяються при повній обробці всіх змін (використовується з асинхронність, наприклад, коли ви отримуєте дані асіхронно від будь-якої фейковий служби)
let fixture: ComponentFixture <MyComponent>>; 
     let component: MyComponent; 
     let dataSource = new MockDataSource();
    
    beforeEach(async(() => { TestBed.configureTestingModule({
            declarations: [ MyComponent ], 
            providers: [ { provide: RestDataSource, useValue: dataSource } ] }); 
    TestBed.compileComponents().then(() => { 
            fixture = TestBed.createComponent( MyComponent );

          // debugElement = fixture.debugElement; 
            component = fixture.componentInstance;
     });
}));

ComponentFixture.debugElement

Властивість ComponentFixture.debugElement – повертає об’єкт типу DebugElement (кореневог елементу шаблону компонента).

debugElement має ряд властивостей і методів:

  • nativeElement – повертає об’єкт, який представляє об’єкт HTML в DOM;
  • children – повертає масив ‘дочірніх’ об’єктів типу DebugElement;
  • query(selectorFunction) – функція selectorFunction отримує в якості параметра об’єкт типу DebugElement для кожного елемента HTML в шаблоні; функція повертає перший об’єкт типу DebugElement, для якого функція поверне true;
  • queryAll(selectorFunction) – аналогічно query, але поверне всі об’єкти типу DebugElement;
  • triggerEventHandler(name, event) – ініціюємо подію, наприклад, за допомогою декоратора @HostListener;

Клас By

Клас By дозволяє знаходити елементи в шаблоні компонента завдяки своїм методам:

  • By.all
  • By.css(selector)
  • By.directive(type)

Unit-тести: тестування сервісів

angular-cli при створенні сервісу генерує файл виду name_service.spec.ts як болванку для тесту. В даному файлі за допомогою хелперів TestBed і inject створюється наша сутність.

import { TestBed, inject } from '@ angular/core/testing';

TestBedдопомагає настроїти модуль, в якому ми повинні вказати provider для нашого сервісу. inject – це хелпер, який допомагає запустити injector по заданому провайдеру, завдяки йому ми отримуємо примірник нашого сервісу (service: ArticleService).

Далі ми проводимо простий тест на те, що сервіс існує:

expect(service).toBeTruthy();

Повний код тесту, який спочатку згенерував angular-cli:

import { TestBed, inject } from '@ angular/core/testing';
import { ArticleService } from './article.service';

  describe('ArticleService', ()=>{
     beforeEach(() =>{ TestBed.configureTestingModule({
      providers: [ ArticleService ] 
    });
  });  
     
  it( 'should be created', inject([ArticleService], ( service: ArticleService ) => {
    expect(service).toBeTruthy>();
  }));
});

Припустимо, в сервісі у нас є метод sum, який повертає результат складання двох чисел:

sum(a: number, b: number): number { return a + b; }

Протестуємо метод sum:

it( 'should return sun', inject([ ArticleService ], ( service: ArticleService ) => { 
    expect(service.sum( 5, 5)).toBe(10); 
}));

Щоб запустити тести необхідно виконати команду (ng test в package.json):

npm run test

Працює з асинхронним методом в сервісі

Асинхронний метод в сервісі sum.service.ts:

// async method 
sumAsync( a: number,  b: number): Promise<any> { return new Promise(function(resolve, reject) { 
        setTimeout(function(){
            resolve( a + b); 
       }, 500 ); });
}

Тестуємо в sum.service.spec.ts:

Для емуляції асинхронности в Angular присутній метод fakeAsync. Також для емуляції асинхронности присутні два додаткових методи, які ми використовуємо всередині методу fakeAsyncflush (flush всередині fakeAsync скине всі асинхронности, тобто виконає їх синхронно), tick – приймає параметр (к-ть мілісекунд), після закінчення яких виконається код в методі fakeAsync.

// test async method
it('should return async sum', fakeAsync(inject([ SumService ], ( service: SumService ) => { 
    service.SumAsync(5, 5).Then( function(data){ // повернутий результат ми можемо затверджувати:
        expect(data).toBe(10);});     
        // тести пройшли і якщо випаде помилка, так як виконується асинхронна операція ніхто чекати не буде
        // можна скористатися методом flush(). flush всередині fakeAsync скине всі асинхронности, тобто виконає їх синхронно.
        // flush ();
        // OR:
        // або ми можемо скористатися всередині fakeAsync методом tick, в який можна передати кількість мілісекунд
        tick>(500); 
})));

Метод whenStable

Метод whenStable повертає об’єкт Promise, дозволяється при повній обробці всіх змін (використовується з асинхронність, наприклад, коли ви отримуєте дані асіхронно від будь-якої фейковий служби). Наприклад, це дає нам можливість дочекатися виконання Observable і лише потім протестувати наші зміни.

@Injectable() class MockDataSource { public data = [ { item: 1, item: 2 } ];
    getData(): Observable { return new Observable( obs => {
            setTimeout(() => obs.next(this.data), 500); 
      })
    }
}
             
describe("TestComponent", ()=>{
    let fixture: ComponentFixture; 
    let component: TestComponent;
    // створюємо фіктивну службу, яка для тесту підміняє службу компонента 
    let dataSource = new MockDataSource();

    beforeEach( async(() => { 
      TestBed.configureTestingModule({
            declarations: [ TestComponent ],
            providers: [ { provide: RestDataSource, useValue: dataSource } ]
      });
      TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent( TestComponent );
            component = fixture.componentInstance;
      });
    }));

    it("async operation success", () => {
        fixture.detectChanges();
        fixture.whenStable().then(() => {
            expect(component.getItems().length).toBe(2);
        }); 
    });
});

Unit-тести: тестування httpClient

Розглянемо як тестувати сервіси, які містять http-запити.

Щоб тестувати http-запити необхідно також налаштувати proxy. У файлі karma.conf.js вкажіть параметр proxies:

proxies: { '/api': { 'target': 'http://localhost:3000/api', 'secure': false, 'changeOrigin': true, 'logLevel': 'info'} }

Якщо консоль буде майоріти помилками виду StaticInjectorError(DynamicTestModule)[HttpClient], то це означає, що в компоненті використовуються http-запити і необхідно імпортувати HttpClientModule:

imports: [ HttpClientModule, ],
@Injectable ({ providedIn: 'root' })
export class CatsService{
    constructor(private httpClient: HttpClient){ }

    getCats(): Observable<any> { return this.httpClient.get('/api/cats'); }
         
    getCat(_id): Observable<any> { return this.httpClient.get(`/api/cat/${_ id}`); }
}

Протестуємо запит на отримання cat по id.

import { TestBed, inject } from '@angular/core/testing'; 
import { CatsService } from './cats.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('CatsService', () => { 
    // HttpClientTestingModule - 
    // HttpTestingController - 
    // допомогою HttpClientTestingModule і HttpTestingController ми можемо управляти http-запитами 
    beforeEach(()  =>  { 
        TestBed.configureTestingModule ({
            imports: [ HttpClientTestingModule ],
            providers: [ CatsService ] 
        });
    });

    it('should be created', inject([ CatsService ], (service: CatsService) => {
        expect(service).toBeTruthy();
    }));
    
    // так як ми робимо unit-тести, то ми не тестуємо сам httpClient 
    // (він протестований розробниками Angular)

    it('should get one cat', inject([CatsService, HttpTestingController], (service: CatsService, backend: HttpTestingController) => {
        // створимо фейковий об'єкт cat (він приходить з сервера)
        const mockCat =  { name: 'asdasd' };
        service.getCat('5b22e86df062b20530788e2d').subscribe(function(cat) {
            // так як приходить об'єкт, то для порівняння використовуємо toEqual
            expect(cat).toEqual(mockCat);
        });
                
        // сформуємо сервіс backend, щоб він повертав нам потрібні дані

        backend.expectOne({
            method: 'Get',
            url: '/api/cat/5b22e86df062b20530788e2d'
        }).flush(mockCat) 
        // flush дозволяє скинути і вказати, що повинен повернути запит 
    }));
>});

Unit-тести: тестування pipe

Створимо pipe, який буде перевертати рядок – reverse-string.

@Pipe ({ name: 'reverseString' }) 
export class ReverseStringPipe implements PipeTransform {
    transform (value: string): string {
        if(typeof value !== 'string') {
            throw new Error('Error on Reverse: not string') 
        }
        
        let str = '';
        for(let i = value.length - 1; i >= 0; i-){
            str +=  value.charAt(i);
        }
        Return str; 
    } 
}

Фільтри ми можемо тестувати як звичайний js-код, тобто тестувати клас, а в цьому класі тестувати метод.

import { ReverseStringPipe } from './reverse-string.pipe';

describe('ReverseStringPipe', () => {
    let pipe;
    beforeEach(() => {
        pipe = new ReverseStringPipe(); 
    });

    // тест на те, що pipe існує
    it('create an instance', () => {
        expect(pipe).ToBeTruthy();
    });

    // тест на те, щo рядок 'перевертається'
    it('reverse success', () => {
        expect(pipe.Transform('abcde')).ToBeTruthy('edcba');
    });

    // * перевіряємо на те, що виключення (exception) було викинуто
    it('should throw on error', () => {
        // всередині expect exception потрібно звернути в функцію
        expect(() => {
            pipe.Transform(1212);
        }).toThrowError('Error on Reverse: not string');
    });
});

Хелпери beforeEach

Хелпер beforeEach дозволяє задати код, який буде виконаний перед кожним тестом. Наприклад, щоб кожен раз не створювати екземпляр pipe (дивіться приклад – Unit-тести: тестування pipe) ми можемо винести його в beforeEach:

describe('ReverseStringPipe', () => {
    let pipe;
    beforeEach(() => {
        pipe = new ReverseStringPipe();
    });
});

Unit-тести: Тестуємо компонент

У компоненті ми будемо отримувати за допомогою методу компонента getCat і сервісу по id певну cat і виводити cat.name в шаблоні. Ми повинні перевірити, що в методі викликається сервіс і то що значення записується в змінну компонента cat.

// cat-info.component.ts 
import { Component, OnInit } from '@angular/core'; 
import { CatsService } from '../../services/cats.service';
      
@Component ({
  selector: 'app-cat-info',
  templateUrl: './cat-info.component.html',
  styleUrls: [ './cat-info.component.css' ] 
}) 
export class CatInfoComponent implements OnInit {
    public cat: any;

    constructor(private catService: CatsService) { }

    ngOnInit() { this.getCat(); }
        
    getCat() { 
        let self = this; 
        self.catService.getCat("5b22e86df062b20530788e2d").subscribe(function(data) {
            self.cat = data;
        });
    }
}
// cat-info.component.spec.ts 
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CatInfoComponent } from './cat-info.component'; 
// імпортуємо так як в компоненті використовуємо запити до апі 
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { CatsService } from "../../services/cats.service"; 
import { of } from "rxjs";

describe('CatInfoComponent', () => { 
    let component: CatInfoComponent; 
    let fixture: ComponentFixture <CatInfoComponent>;
    let catService: CatsService;
    let spy: jasmine. Spy;
    let mockCat;

    beforeEach(async(() => { TestBed.configureTestingModule({
            imports: [ HttpClientTestingModule ],
            declarations: [ CatInfoComponent ],
            providers: [ CatsService ]
    }).compileComponents();
})); 

beforeEach(() => {
    fixture = TestBed.createComponent(CatInfoComponent);
    
    // instance компонента
    component = fixture.componentInstance;
    
    // injector - це сутність, яка дозволяє створювати залежності для компонента
    // (ми отримуємо injector для нашого конкретного компонента) . Отримуємо CatsService:
    catService = fixture.debugElement.injector.get(CatsService);
    mockCat = {
       Name: "asas"
    };
    // нам потрібен шпигун на метод getCat; також повернемо значення, яке повертає метод getCat
    spy = spyOn(catService, 'getCat').and.returnValue(of(mockCat))
        fixture.detectChanges();
    });

    // перевіряємо на існування компонента
    it('should create', () => {
        expect (component ).ToBeTruthy();
    });

    // перевіримо що метод сервісу викликається
    // для цього нам будуть потрібні спеціальні суті - шпигуни
    // (jasmine spy, https://jasmine.github.io/api/2.9/Spy.html).
    // Коли ви створюєте сутність spy, то вона буде зберігати всі виклики
    // і звернення до цього spy, також вона може повертати будь-які
    // значення замість реальної суті. 

    it('should call catService.getCat', () => { 
        // шпигун був викликаний як завгодно раз
        expect(spy.calls.any()).toBeTruthy();
    });

    // перевіримо що сервіс записує значення після виклику сервісу
    it('should set cat(cat.name)', () => {
        expect(component.Cat.Name).ToEqual('asas');
    });
});

Unit-тести: Тестуємо директиву

Наша директива Emit (передає наверх) батьківського компоненту кількість кліків.

// count-click.directive.ts
@Directive({ selector: '[appCountClick]'}) 
export class CountClickDirective {

    @Output() changes = new EventEmitter<number>();
    private count = 0;

    @HostListener('click')
    onClick() {
        this.count++;
        this.changes.emit(this.count);
    }
}

Використання директиви:

<div appCountClick(changes) = "getClick ($ event)"> click me </ div>

Для директиви Angular в заготівки робить екземпляр від класу директиви; цього мало, але так як директиви дуже різнобічні розробники Angular вирішили, що цього достатньо. Нам необхідно протестувати роботу декораторів @Output і @HostListener, для цього потрібно навісити кудись подію і висновок. Тому створимо тестовий компонент в файлі count-click.directive.spec.ts:

// count-click.directive.spec.ts
// тестовий компонент 
@Component({ 
    template: `<div appCountClick (changes) = "outCount = $event"> click me </ div> ` 
})

export class TestCountComponent { public outCount = 0; }

describe('CountClickDirective', () => {
    let testCountComponent, fixture;

    // инициализируем тестовий компонент
    beforeEach(() => { 
        // налаштуємо модуль
        TestBed.ConfigureTestingModule({
            declarations: [ TestCountComponent, CountClickDirective ]
        });

        fixture = TestBed.createComponent(TestCountComponent);
        // отримаємо екземпляр компонента
        testCountComponent = fixture.componentInstance;
    })

    it('should create an instance', () => { 
        const directive = new CountClickDirective();
        expect(directive).toBeTruthy();
    });  
          
    // перевіримо кліки по диву
    it('should count click', () => {
        // отримуємо div, для якого ми ставимо директиву appCountClick 
        let div =  fixture.NativeElement.QuerySelector('div');
        div.Click();
        expect(testCountComponent.outCount).toBe(1);
    });
});