Интерактивный задачник по JS

«Теория без практики — мертва, практика без теории — слепа» (приписывают Суворову)

В плане теории есть большое кол-во ресурсов, которые дают хорошую базу знаний по JS. А вот возможностей попрактиковаться не так много, как бы хотелось.

Мне вспоминается мой 1-й курс обучения в университете. Только тогда нам давали реально много разных задачек по алгоритмам/структурам данных и т.д. Это делалось в контексте Turbo Pascal, что сейчас не особо радует. Однако сам механизм подхода очень хороший. Ты просто набиваешь руку и мозг. Вначале решаешь что-то простое, затем более сложное, а когда доходишь до очень сложных (по тем меркам) вещей, то обнаруживаешь, что эти сложные задачи можно разбить на более простые (частично уже решенные ранее). Чем-то похоже на школьный сборник задач Сканави. Вот уж где практики было хоть отбавляй.

Что мешает сделать такого рода задачник для выбранного ЯП? Да ничего. Просто придумывай условия да и все. А нет. Толку будет немного. Ведь запрограммированный алгоритм надо как-то проверить. Тут не получится просто сравнить с заранее данным ответом. Нужно дать решающему несколько наборов тестовых данных, чтоб он сам себя мог проверить (качество кода — тоже важный фактор, однако пока его не трогаем). А еще было бы неплохо автоматизировать этот процесс. То есть, человек написал функцию, которая решает одну из задач, а другая программа ее проверила и вынесла вердикт — решение правильно или нет (проверку оптимальности решения, увы, автоматизировать нельзя — только «вручную» сторонним наблюдателем).

Немного критериев для задачника:

  • Является веб-приложением.
  • Должен запускаться локально и требовать подключения к сети только на этапе установки.
  • Установка не должна быть сложной.
  • Каждая задача — это отдельная функция, в плане своего решения.
  • Для каждой задачи четко указано, что она ожидает на входе и что возвращает на выходе.
  • Для каждой задачи есть набор тестовых данных, который покрывает основные «кейсы» (покрыть все не получится все равно — см. задачу о треугольнике).
  • Должна быть возможность запускать тесты для каждой задачи отдельно и для всех вместе.
  • Запуск тестов должен быть тривиальной задачей на уровне одного-двух кликов мышкой (никто не любит сложностей).
  • Если условие задачи кажется немного запутанным, то дать пример input/output (раньше времени решающему не надо смотреть в тесты).
  • Каждая задача должна относиться к одной либо нескольким категориям (работа со строками/числами/объектами/массивами и т.д).
  • Рядом с каждой задачей должно быть написано решение, предложенное пользователем.

Инструментальная база

Для себя я решил, что задачник будет как приложение на EmberJS. Используя ember-cli можно поднять под него небольшой локальный сервер, да и тесты с qUnit (и оберткой ember-qunit) удобно составлять/запускать.

Установка/запуск — все как в любом Ember-приложении (если у Вас уже установлены Git, Node.js (with NPM), Bower, Ember CLI, PhantomJS):

git clone https://github.com/onechiporenko/js-practice
cd js-practice
npm i
bower i
ember serve

Теперь задачник доступен по адресу http://localhost:4200 (запускать желательно в актуальных версиях браузеров).

Может показаться, что третий пункт требований («установка должна быть простой») идет немного вразрез с описанными выше действиями, но это не так. Git, нода (вместе с npm), да и bower у пользователя скорее всего уже установлены. PhantomJS — штуке полезная для почти любого js-проекта (пригодится в будущем). Ember-cli ставится одной командой (времени много не займет), а клонирование и разворачивание стороннего проекта локально — задача весьма распространенная для программиста.

Структура приложения (папка app):

app
│   app.js
│   index.html
│   router.js
│
├───adapters
│       application.js
│       tasks.js
│
├───controllers
│   │   tasks.js
│   │
│   └───solutions
│           ajax.js
│           arguments.js
│           arrays.js
│           collections.js
│           date.js
│           numbers.js
│           objects.js
│           regexp.js
│           strings.js
│
├───helpers
│       controller-method.js
│       link-to-tests.js
│
├───initializers
│       dummy.js
│
├───models
│       chapter.js
│       task.js
│
├───routes
│       application.js
│       tasks.js
│
├───styles
│       app.css
│
├───templates
│   │   application.hbs
│   │   index.hbs
│   │   tasks.hbs
│   │
│   ├───partials
│   │       solution.hbs
│   │       task.hbs
│   │
│   └───solutions
│           dom.hbs
│
└───views
    └───solutions
            dom.js

Что тут интересного для того, кто хочет задачник расширить?

В models есть две модели — chapter и task. Первая являет собой Раздел с несколькими задачами, а вторая — это непосредственно задача.

В adapters хранятся два файла, которые обеспечивают подгрузку данных по моделям из нужных мест.

helpers содержат парочку вспомогательных элементов для шаблонов. Теоретически, controller-method можно было бы сделать как Component, но «и так работает».

В initializers в Ember добавляется метод-заглушка, который используется как начальное «решение» каждой задачи. В тестах он не включается, потому они по началу все «красные».

routes — просто роуты приложения 🙂

styles — лучше вообще не смотреть.

controllers/tasks.js — тут решается что выводить — задачи одного раздела или же все задачи

controllers/solutions/*.js — каждый файл соответствует какому-то разделу задач (имя файла должно совпадать в id модели Chapter). В каждом файле содержится столько методов-заглушек, сколько задач есть в разделе. При добавлении задачи необходимо выбрать имя метода, в котором она будет решена и сделать соответствующую заглушку в нужном контроллере.

views/solutions/*.js — каждый файл соответствует какому-то разделу задач (имя файла должно совпадать в id модели Chapter). Дежавю с предыдущим абзацем? Есть такое. Однако, тут стоит взять во внимание, что, например, задачи по манипуляции DOM тоже имеют место быть и их надо тестировать. Раз уж мы выбрали Ember как основу, то необходимо следовать его правилам. Но про тестирование чуть ниже.

Что тут интересного для того, кто собирается задачки решать?

Интересовать тут могут следующие места — controllers/solutions/*.js и views/solutions/*.js, так как именно тут и надо записывать свои решения. Все 🙂

Немного о тестировании

Тестовый каталог содержит следующее:

tests
│
├───acceptance
│   └───solutions
│       │   dom-test.js
│       │
│       └───dom
│               ******-test.js
│
└───unit
    │
    ├───controllers
    │   │
    │   └───solutions
    │       │
    │       ├───ajax
    │       │       ******-test.js
    │       │
    │       ├───arguments
    │       │       ******-test.js
    │       │
    │       ├───arrays
    │       │       ******-test.js
    │       │
    │       ├───collections
    │       │       ******-test.js
    │       │
    │       ├───date
    │       │       ******-test.js
    │       │
    │       ├───numbers
    │       │       ******-test.js
    │       │
    │       ├───objects
    │       │       ******-test.js
    │       │
    │       ├───regexp
    │       │       ******-test.js
    │       │
    │       └───strings******-test.js

Есть два раздела — acceptance (для views) и unit (для controllers). Повторюсь, пока что во view содержатся тесты только для работы с DOM. А что такое ******-test.js? Это файлы с тестами под каждую задачу. Например, у нас есть задача для раздела numbers. Имя ее функции — myCoolTask. Тогда в tests/unit/controllers/solutions/numbers/ должен быть файл с именем myCoolTask-test.js и структурой вида:

import { moduleFor, test } from 'ember-qunit';
 
moduleFor('controller:solutions/strings', 'solutions.strings.myCoolTask', {});
 
test('test1', function(assert) {
  // ....
});
 
test('test2', function(assert) {
  // ....
});

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

Предположим, что Вы написали решение и хотите проверить его правильность. Находим на странице эту задачу и рядом с условием есть ссылка «проверить тесты». Жмем ее. В отдельной вкладке запускается пакет тестов. Для каждого теста описано, что он дает функции на входе и что ожидает на выходе.

Для удобства пользования, рядом с каждым условием выводится решение, предложенное пользователем. Делается это через небольшой «рефлексийный» костыль. Как говорилось выше, есть такой controllers/tasks, который отвечает за вывод всех задач или задач одного раздела. Внутри него как computed property объявлено needs в виде (раньше не знал, что так можно делать):

needs: Ember.computed(function () {
    return this.store.all('chapter').filterBy('section', 'controllers').map(chapter => {
        return 'solutions/' + chapter.get('id');
    });
}),

chapter в данном случае — это массив моделей Chapter. Из них выбираются те, решения которых описываются в контроллерах (в модели есть поле section), и, так как id модели совпадает с именем «нужного» контроллера (сделано умышленно), они подключаются в tasks. Далее, есть функция (описана как helper) вида:

function controllerMethod(params/*, hash*/) {
  var controller = params[0];
  var chapterId = params[1];
  var section = params[2];
  var methodName = params[3];
  var method;
  if (!chapterId) {
    return '';
  }
    if (section === 'controllers') {
      method = controller.get('controllers.solutions/' + chapterId)[methodName];
    }
  return method ? method.toString() : '';
}

Ее вызов в шаблоне осуществляется так:

{{controller-method this task.chapter.id task.chapter.section task.method}}

this — это контроллер tasks, task.chapter.id — это название секции (а по совместительству имя контроллера из needs в tasks), task.chapter.section — секция, где находится решение (controllers/views), task.method — имя метода с решением текущей задачи (тело этого метода и необходимо вывести).

А обстоят дела с views (в частности для задач с DOM)? Тут пришлось немного повозиться и с идеей, и с реализацией. Будем смотреть на примере все той же категории задач dom. Ее view находится в views/solutions/dom.js, а ее шаблон, соответственно, в templates/solutions/dom.hbs.

Очевидно, что проверку задач такой категории можно делать только через acceptance-тесты. То есть, под каждую задачку должен быть свой шаблон (либо «кусок» шаблона — как угодно) и какой-то «action», который вызывает функцию-решение. Берем задачу под названием myCoolTask2. Внутри шаблона dom.hbs создаем блок с классом myCoolTask2. Внутри него размещаем элементы, на которые должно повлиять решение задачи. И (самое главное!) делаем кнопку с классом myCoolTask2, по клику на которую вызывается функция-решение.

<div class="myCoolTask2">
    <button {{action "myCoolTask2" target="view"}} class="myCoolTask2">myCoolTask2</button>
</div>

Так как, функция-решение вызывается как action, то описана она должна быть как action:

// app/views/solutions/dom.js
import Ember from 'ember';
 
export default Ember.View.extend({
 
  actions: {
    myCoolTask2: Ember.B
  }
 
});

Тестовый файл для данной задачи будет находиться в tests/acceptance/solutions/dom/myCoolTask2-test.js:

import Ember from 'ember';
import { module, test } from 'qunit';
import startApp from '../../../helpers/start-app';
 
var application;
 
module('solutions.dom.myCoolTask2', {
 
  beforeEach: function() {
    application = startApp();
  },
 
  afterEach: function() {
    Ember.run(application, 'destroy');
  }
 
});
 
test('test1', function(assert) {
  visit('/solutions/dom');
  andThen(function() {
    click('button.myCoolTask2');
    andThen(function () {
      // some assertions
    });
  });
});

Хорошо, а как вывести код решение задачи рядом с условием, если оно находится во view.actions? Увы, без хаков и костылей тут не обойтись (по крайней мере, мне не удалось). В первую очередь необходимо, чтобы глобально была доступна ссылка на Ember.Application. Топорный (но рабочий) вариант — это добавить в файл app/app.js строку window.App = App; перед export’ом. Далее, в полученном App нужно найти правильный NAMESPACE:

App.NAMESPACES.findBy('name')

Почему так? В моем случае, у других Namespaces поле name было вообще не определено. Теперь берем __container__ и делаем у него lookup вида:

var chapterId = 'dom';
var view = App.NAMESPACES.findBy('name').__container__.lookup('view:solutions/' + chapterId);

А как из view вытянуть actions? Надо брать _actions:

var chapterId = 'dom';
var methodName = 'myCoolTask2';
var methodBody = App.NAMESPACES.findBy('name').__container__.lookup('view:solutions/' + chapterId)._actions[methodName].toString();

Да, найти такое «решение» было непросто. Но, «оно работает». Вышеуказанный код добавляем в controllerMethod (который теперь уже работает не только с контроллерами, но название менять нет желания):

import Ember from 'ember';
 
export function controllerMethod(params/*, hash*/) {
  var controller = params[0];
  var chapterId = params[1];
  var section = params[2];
  var methodName = params[3];
  var method;
  if (!chapterId) {
    return '';
  }
  if (section === 'views') {
    method = App.NAMESPACES.findBy('name').__container__.lookup('view:solutions/' + chapterId)._actions[methodName];
  }
  else {
    if (section === 'controllers') {
      method = controller.get('controllers.solutions/' + chapterId)[methodName];
    }
  }
  return method ? method.toString() : '';
}
 
export default Ember.HTMLBars.makeBoundHelper(controllerMethod);

Вместо послесловия

Задачник доступен на github — js-practice. Сейчас там 101 задачка. Тестами покрыты все. Качество покрытия — «разнонаправленное» 🙂 Иногда бывают ошибки в самих тестах — фикшу по мере возможности.

Задачник позволяет проверить правильность решения только с точки зрения тестов. Качество решения должно проверяться сторонним человеком. Автоматизировать это нельзя никак.

, , , ,

Оставить комментарий

Top ↑ | Main page | Back