Pull to refresh

BALLSORT на $mol. Часть 1

Level of difficultyMedium
Reading time14 min
Views2.8K

Сегодня мы перепишем на $mol эту демку почти пиксель в пиксель и напишем несколько тестов.

Демка представляет собой игру, в которой перемещаются разноцветные шарики между трубками, цель игры - отсортировать шарики по цветам за наименьшее количество шагов.

Изначально она была реализована на эффекторе + react, недавно несколько человек реализовали ее

Там где не указана ссылка на исходники отдельно, она есть в самой демке

Постановка задачи

Экраны

  • Start - стартовый экран на котором отображается заголовок, кнопка для запуска игры, и подвал с cсылками

  • Game - при клике на кнопку запуска, открывается экран с игрой, на котором необходимо сортировать шарики. В хедере находятся кнопки возврата на стартовый экран и рестарта игры, а также счетчик числа сделаyных шагов. В центре трубки с шарами. В подвале те же ссылки что и на первом экране.

  • Finish - когда шарики отсортированы, поверх второго экрана отображается третий экран. На нем находится заголовок "You won!", количество сделанных шагов, и кнопка "New game" которая открывает стартовый экран.

Механика игры

gif
gif
  1. Рисуются 6 трубок, четыре и них заполнены шарами и две пустые

  2. В заполненных трубках находятся по 4 шара, четырех разных цветов

  3. При клике на непустую трубку, она переходит в активное состояние

    • В активном состоянии верхний шар в трубке переносится на ее крышку

  4. Повторный клик по активной трубке дезактивирует ее, шар переносится обратно в нее

  5. После активации трубки, клик по другой трубке переносит шар с крышки в другую трубку при условии, что другая трубка пуста или верхний шар другой трубки такого же цвета как шар на крышке активной трубке

  6. Когда в одной и трубок все 4 шара одного цвета она переходит в статус готово, после этого шары в нее/из нее перемещать нельзя.

  7. Игра закончится, когда 4 трубки перейдут в статус готово.

Подготовка

Начнем с самого начала, а именно с разворачивания мола и создания репозитория под проект.

Установка MAM-окружения

Можно использовать gitpod.io, окружение установится автоматически, согласитесь установить плагины. Или можно установить все локально:

  1. Обновите NodeJS до LTS версии

  2. Загрузите репозиторий MAM

git clone https://github.com/hyoo-ru/mam.git ./mam && cd mam
  1. Установите зависимости, вашим пакетным менеджером

npm install
  1. Установите плагины для VSCode EditorConfig vscode-language-tree

MAM-окружение достаточно установить один раз и использовать для всех проектов!

Создание и настройка репозитория

  1. Идем сюда и нажимаем "Use this template" => "Create a new repository"

  2. Выбираем владельца, указываем имя репозитория "ballsort", опционально заполняем описание, тип репозитория ставим публичным и нажимаем "Create repository from template"

  3. Откройте настройки созданного репозитория нажав на "Settings"

  4. В левом меню нажмите на "Actions" => "General", в разделе "Workflow permissions" отметьте чекбокс "Read and Write permissions" и нажмите "Save". Это нужно чтобы экшен деплоя на "github pages" мог задеплоить приложение.

В качестве неймспейса будем использовать имя "hype" и опустим создание репозитория под неймспейс.

  1. Копируем ссылку на репозиторий и клонируем его в директорию mam/hype/ballsort

cd mam
# Только подставьте вашу ссылку
git clone https://github.com/PavelZubkov/ballsort.git hype/ballsort

Минималное приложение

  1. Запускаем дев-сервер следующей командой

yarn start
  1. Открываем в браузере http://127.0.0.1:9080

  2. Вы увидите список файлов и директорий, расположенных в директории mam. Нажмите на "hyoo", затем на "ballsort", затем на "app" - откроется белый экран, это ок т.к. в app присутствует только файл index.html.

  3. Откройте файл hype/ballsort/app/index.html и укажите имя модуля который будет монтироваться в атрибуте mol_view_root

<div mol_view_root="$hype_ballsort_app"></div>
  1. В директории app создайте файл app.view.tree с содержимым ниже и сохраните его.

$hype_ballsort_app $mol_view
	sub / \Hello
  1. Вернитесь в браузер и если все верно вы увидите приветствие

Деплой на github

В readme.md есть чек лист для настройки шаблонного репозитория

  1. Переименуйте файл hype/ballsort/hyoo_template_app.yml в hype/ballsort/hype_ballsort_app.yml и откройте его

  2. Измените имя на 3 строке

name: $hype_ballsort_app
  1. На 19 строке укажите какой модуль будет собираться

	- uses: hyoo-ru/mam_build@master2
	with:
		package: 'hype/ballsort'
		modules: 'app'
  1. Удалите блок деплоя в NPM, он начинается на 26 строке и заканчивается на 30 строке

  2. В блоке деплоя на Github Pages измените путь до директории с бандлами

	- uses: hyoo-ru/gh-deploy@v4.4.1
	if: github.ref == 'refs/heads/master'
	with:
	folder: 'hype/ballsort/app/-'
  1. Сделайте коммит и отправьте изменения в репозиторий на github

  2. Возвращаемся в гитхаб, в разделе "Actions" ждем когда завершиться action "$hyoo_ballsort_app", и после него запуститься экшен "pages build and deployment"

  3. Если второй экшен упадет, то открываем "Settings" => "Pages", в разделе "Branch" указываем ветку для деплоя "gh-pages" и нажимаем "Save". После этого второй экшен запуститься повторно, а после его завершения в разделе настроек "Pages" будет находится ссылка на приложение.

  4. Если будут проблемы можете написать тут

Модель

Сначала напишем модель игры независимо от ее view-представления, а уже после отрисуем ее.

Я разделил игру на три модуля:

  • game - основная логика

  • ball - шар, тут только хранение цвета шаром

  • tube - логика трубы

Начнем с ball

  1. Создайте директорию ball и ts-файл в ней mam/hype/ballsort/ball/ball.ts

  2. Для VSCode в MAM-окружении доступно несколько сниппетов

    • class - шаблон для файла с классом

    • logic - шаблон для создания класса с логикой для view-компонента

    • styles - шаблон для css.ts-файла со стилями

    • tests - шаблон для файла с тестами

  3. Введите слово class, выберите "MAM class definition" и нажмите TAB или ENTER

  4. Введите имя класса $hype_ballsort_ball и он должен наследоваться от $mol_object

$mol_object - это базовый класс с общей логикой, можете посмотреть его исходники самостоятельно. Т.к. имя сущности соответствует расположению сущности в исходном коде, то сможете без труда найти его. Репозиторий mol загрузился в MAM-окружение при установке сборщика. Можно просто нажать CTRL+P, ввести mol/object и нажать ENTER.

Сейчас у вас есть пустой класс:

	namespace $ {
		export class $hoop_ballsort_ball extends $mol_object {
	
		}
	}

ball будет хранить одно состояние - цвет шара, создадим свойство для него

	namespace $ {
		export class $hype_ballsort_ball extends $mol_object {
			
			@ $mol_mem
			color(next?: number) {
				return next ?? 0
			}	
			
		}
	}

В качестве значения цвета, мы будем использовать целые числа по порядку с 0 и далее. А при отображении view-компонент сам определит для какого числа какой цвет использовать.

Как это работает

При вызове метода без аргументов, он работает как геттер. При вызове с аргументом как сеттер.

Декоратор кеширует возвращенное значение из метода при первом вызове, а при повторном уже не запускает код метода, а просто возвращает значение из кеша.

Вновь код метода будет запущен только в двух случаях:

  • если передали в него новое значение

  • если код метода использует другие методы с декоратором, то в случае изменения их значения, декоратор поймет, что закешированное значение уже неактуально и при следующем вызове запустит код метода, чтобы получить актуальное значение

	const obj = new $hype_ballsort_ball
	obj.color() // 0
	obj.color(1) // 1
	obj.color() // 1

tube

Создайте директорию tube и ts-файл в ней mam/hype/ballsort/tube/tube.ts

За что будет отвечать трубка

  • хранить массив шаров помещенных в нее

  • определять находится ли она в состоянии готово

  • выдавать нам верхний шар

  • принимать от нас шар и класть наверх

Создайте класс, назовите его $hype_ballsort_tube и отнаследуйте от $mol_object.

	namespace $ {
		export class $hype_ballsort_tube extends $mol_object {
			
		}
	}

Добавим свойство для хранения шаров. Тут все точно также, как и у свойства color у шара, только в качестве значения используется массив, в котором хранятся объекты - инстансы класса $hype_ballsort_ball. По умолчанию возвращается пустой массив.

	namespace $ {
		export class $hype_ballsort_tube extends $mol_object {
			
			@ $mol_mem
			balls( next?: $hype_ballsort_ball[] ) {
				return next ?? []
			}
			
		}
	}

Чтобы отформатировать код также как у меня, нажмите CTRL+SHIFT+P, введите "Format" и выберите команду "Format document" :)

Теперь добавим свойство для определения состояния готово. Ему нужно знать сколько шаров одного цвета должно быть в трубке для перехода в готово, для этого добавим свойство size, без декоратора, оно будет переопределяется при инстанцировании класса.

	namespace $ {
		export class $hype_ballsort_tube extends $mol_object {
			//...

			size() {
				return 0
			}

			@ $mol_mem
			complete() {
				const [ ball, ...balls ] = this.balls()
				return this.balls().length === this.size() && balls.every( obj => obj.color() === ball.color() )
			}
			
		}
	}

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

Декоратор тут тоже кеширует возвращаемое значение, но само свойство read-only, т.к. в нем не предусмотрена передача значения при вызове. Оно зависит от свойства balls и свойств color у шаров, когда они изменятся, оно сбросит кеш и вернет актуальное значение.

И нам осталось добавить только свойства для вытаскивания верхнего шара и для того чтобы положить шар наверх.

	namespace $ {
		export class $hype_ballsort_tube extends $mol_object {
			//...

			@ $mol_action
			take() {
				const next = this.balls().slice()
				const ball = next.pop()
				this.balls( next )
				return ball
			}

			@ $mol_action
			put( obj: $hype_ballsort_ball ) {
				this.balls( [ ...this.balls(), obj ] )
			}
			
		}
	}
  • take

    • берет массив из свойства balls

    • создает его копию. Нельзя мутировать массив, который хранится в декораторе!

    • из копии вытаскивает верхний шар

    • записывает обратно в balls массив без верхнего шара

    • и возвращает шар

  • put

    • принимает шар в качестве аргумента

    • записывает в свойство balls новый массив, который создается из старого плюс принятый шар

game

Переходим к основной логике игры.

  1. Создайте директорию game и ts-файл в ней mam/hype/ballsort/game/game.ts

  2. Создайте класс, назовите его $hype_ballsort_game и отнаследуйте от $mol_object

	namespace $ {
		export class $hype_ballsort_game extends $mol_object {
			
		}
	}

Мы не будем хардкодить сказанное в правилах, что заполненных трубок только четыре, что всего четыре цвета у шаров и т.д. Для начала создадим свойства в которых будут храниться эти константы

	namespace $ {
		export class $hype_ballsort_game extends $mol_object {
			
			color_count() { return 4 } // Количество цветов

			// Количество шаров одного цвета
			// которое надо собрать в трубке
			// для перехода в состоянии готово
			tube_size() { return 4 }

			// Количество пустых трубок
			tube_empty_count() { return 2 }

			// Общее количество трубок
			tube_count() { return this.color_count() + this.tube_empty_count() }

			// Общее количество шаров
			ball_count() { return this.tube_size() * this.color_count() }
			
		}
	}

Теперь нам нужно научиться инстанцировать шары и создать требуемое количество шаров.

	namespace $ {
		export class $hype_ballsort_game extends $mol_object {
			//...

			@ $mol_mem_key
			Ball( index: number ) {
				return new $hype_ballsort_ball
			}
			
		}
	}

Как это работает 2?

Декоратор $mol_mem_key работает точно также, как и декоратор $mol_mem, за одним исключением - первым аргументом он всегда принимает ключ. Ключ является обязательным параметром. В итоге у нас получает набор из произвольного количества состояний, с доступом к каждому по ключу.

В данном случае свойство Ball является read-only свойством, т.к. у него нет второго параметра next. Оно возвращает инстанс класс, т.е. это свойство-фабрика. А в качестве ключей будут использоваться индексы и у шаров и у трубок, но вообще можно использовать произвольный объект.

При вызове с любым индексом, оно вернет объект и запомнит его под этим индексом, и при следующих обращениях будет возвращать уже созданный объект из кеша.

Важно: инстанцировать объекты необходимо через свойства-фабрики!

	const obj = new $hype_ballsort_game
	const ball1 = obj.Ball(0) // возвращает объект - инстанс шара
	const ball2 = obj.Ball(1)
	ball1 === ball2 // false - это два разных инстанса

Теперь создадим свойство генерирующее шары

	namespace $ {
		export class $hoop_ballsort_game extends $mol_object {
			//...

			@$mol_mem_key
			Ball( index: number ) {
				return new $hype_ballsort_ball
			}

			@$mol_mem
			balls() {
				return Array.from( { length: this.ball_count() } ).map( ( _, index ) => {
					const obj = this.Ball( index )
					obj.color( index % this.tube_size() )
					return obj
				} )
			}
			
		}
	}
  • Свойство balls при первом запуске создаст массив с шарами и вернет его, а декоратор закеширует этот массив. При последующих вызовах будет возвращать массив из кеша. Работает так:

  1. Создаем массив через Array.from с указанным количеством элементов ball_count()

  2. Для каждого индекса в массиве создаем шар через Ball и устанавливаем этому шару цвет

  3. Возвращаем массив из свойства

Создаем трубки

Трубки создаются похожим образом

	namespace $ {
		export class $hoop_ballsort_game extends $mol_object {
			//...

			@ $mol_mem_key
			Tube( index: number ) {
				const obj = new $hype_ballsort_tube
				obj.size = () => this.tube_size()
				return obj
			}

			@ $mol_mem
			tubes() {
				const balls = $mol_array_shuffle( this.balls() )
				const size = this.tube_size()

				return Array.from( { length: this.tube_count() } ).map( ( _, index ) => {
					const obj = this.Tube( index )
					const list = index < this.color_count() ? balls.slice( index * size, index * size + size ) : []
					obj.balls( list )
					return obj
				} )
			}
			
		}
	}
  • Свойство-фабрика Tube работает аналогичным образом, как и Ball, только оно после создания объекта устанавливает ему size, мы говорили про это выше - оно нужно трубке чтобы определить готовность.

  • Свойство tubes

    1. Получает шары и перемешивает их через $mol_array_shuffle

    2. Кладет в переменную size, для более короткой записи при использовании

    3. Через Array.from создает массив, длина которого сразу учитывает и пустые трубки

    4. Для каждого элемента мы создаем трубку

    5. Устанавливаем шары для не пустой трубке или пустой массив если трубка должна быть пустой

    6. И возвращаем полученный массив трубок

Дело за малым

Нам потребуется свойства moves в котором будем хранить число шагов и увеличивать с каждым ходом.

	namespace $ {
		export class $hoop_ballsort_game extends $mol_object {
			//...

			@$mol_mem
			moves( next?: number ) {
				return next ?? 0
			}
			
		}
	}

Нам понадобится свойство для хранения активной трубки. Напомню: при клике пользователя по трубке, она становится активной.

	namespace $ {
		export class $hoop_ballsort_game extends $mol_object {
			//...
			
			@ $mol_mem
			tube_active( next?: $hype_ballsort_tube | null ) {
				if (next?.balls().length === 0) return null
				if (next?.complete()) return null
				return next ?? null
			}
			
		}
	}

Это изменяемое свойство - у него есть параметр next, хранит оно объект активной трубки. А также оно принимает значение null, оно туда будет передаваться, когда необходимо дезактивировать трубку.

А также

  • Если в трубке шаров нет - то ее нельзя активировать

  • Если трубка уже в состоянии готово - ее тоже нельзя активировать

Теперь напишем свойство, которое будет переносить шар из активной трубки from, в нужную to.

	namespace $ {
		export class $hoop_ballsort_game extends $mol_object {
			//...
			
			@ $mol_action
			ball_move( to: $hype_ballsort_tube ) {
				const from = this.tube_active()

				if (to === from || !from) return this.tube_active(null)

				const from_color = from?.balls().at(-1)?.color()
				const to_color = to.balls().at(-1)?.color()
				if (to.balls().length && from_color !== to_color) return

				const ball = from.take()!
				to.put( ball )

				this.moves( this.moves() + 1 )
				this.tube_active( null )
			}
			
		}
	}
  1. На вход принимаем объект трубки, в которую будем переносить шар из активной трубки

  2. Если активной трубки нет или активная трубка и трубка, в которую переносим это одна трубка - снимаем с трубки активность и выходим

  3. Проверяем что цвета верхних шаров в обоих трубках совпадают, т.к. друг на друга можно класть шары только одого цвета

  4. Если все ок, то методами take и put достаем шар из одной и кладем в другую

  5. Увеличиваем счетчик шагов moves

  6. Дезактивируем трубку

Предпоследний штрих

Чтобы не сваливать на view-компонент задачу поочередного вызывания tube_active и ball_move, добавим свойство tube_click.

	namespace $ {
		export class $hoop_ballsort_game extends $mol_object {
			//...
			
			@ $mol_action
			tube_click( tube: $hype_ballsort_tube ) {
				const tube_active = this.tube_active()

				tube_active === null ? this.tube_active( tube ) : this.ball_move( tube )
			}
			
		}
	}

View-компонент будет вызывать этой свойство, передавая туда трубку по которой кликнул пользователь.
Логика проста:

  • Если при клике активной трубки нет, то делаем активной переданную трубку

  • Если активная трубка уже есть, то вызываем ball_move, что бы шар переместился из активной в переданную трубку

Последний штрих

Нам нужно свойство, которое будет сигнализировать о том, что игра закончена.

	namespace $ {
		export class $hoop_ballsort_game extends $mol_object {
			//...
			
			@ $mol_mem
			finished() {
				return this.tubes().every( tube => tube.complete() || tube.balls().length === 0 )
			}
			
		}
	}

Игра заканчивается, когда каждая трубка в статусе готово или у нее нет шаров.

Мы тут специально не обрабатываем некоторые случаи, например в активации трубы нет проверки на то что игра закончена, т.к. предполагаем что интерфейс не будет обрабатывать клики на трубки после окончания игры.

Время тестов

Создайте файл game.test.ts в директории hype/ballsort/game, и выполните сниппет tests.

	namespace $.$$ {
		$mol_test({
			
			""( $ ) {
				
			},
			
		})
	}

Как это работает 3?

Для описания тестов есть функция $mol_test, она принимает объект с тестами. Каждый тест - это метод на этом объекте. Имя метода - название теста, а код метода - это код теста. Так же в метод при запуске передается контекст, но это уже совсем другая история.

Сначала напишем простой демо-тест.

	namespace $.$$ {
		$mol_test({
			
			"Moves initially zero"() {
				const obj = new $hype_ballsort_game

				$mol_assert_equal(obj.moves(), 0)
			},
			
		})
	}

Чтобы запустить тесты, обычно ничего делать не надо, когда мы открываем в браузере какое-то приложение, dev-сервер собирает отдельный бандл с тестами, который содержит тесты всех модулей от которых зависит приложение.

Можно заметить, что в урле запрашивается файл test.html, в него сборщик добавляет загрузку бандла с тестами. Тесты прогоняются при каждой перезагрузке страницы.

Но у нас пока в приложении выводится только приветствие, мы можем попросить dev-сервер отдать нам test.html модуля game, он положит туда тест, который мы написали.

Откройте ссылку http://127.0.0.1:9080/hoop/ballsort/game/-/test.html - вы увидите белый экран, в game нет view-компонентов. Откройте консоль в девтулзах.

консоль с репортом о прошедших тестах
консоль с репортом о прошедших тестах

Зеленьким "All test passed" - ни один тест не упал. Число 92 - количество запущеных тестов, это тесты модулей от которых зависит наш код.

Сломайте тест, вместо 0 поставив 1, сохраните и загляните в консоль. Тест упал:

консоль с репортом о фейле теста
консоль с репортом о фейле теста

Можете удалить демо-тест.

Переход трубок в состояние готово

Для начала проверим что трубка корректно переходит в состояние готово.
Нам нужно:

  1. Создать игру

  2. Достать заполненную трубку

  3. На всякий случай убедимся, что изначально нет трубок в состоянии готово

  4. Установим всем шарам одинаковый цвет

  5. Убедимся, что трубка перешла в состояние готово

	namespace $ {

		$mol_test( {

			'tube completing'() {

				const game = new $hype_ballsort_game // 1
				const tube = game.tubes().find( obj => obj.balls().length > 0 )! // 2
				$mol_assert_not( tube.complete() ) // 3

				tube.balls().forEach( ball => ball.color( 0 ) ) // 4

				$mol_assert_ok( tube.complete() ) //5

			}

		} )
	}

Проверим что трубка в состоянии готово не активируется

	namespace $ {

		$mol_test( {
			//...

			'completed tube non activation'() {
				// Создаем игру и берем трубку с шарами
				const game = new $hype_ballsort_game
				const tube = game.tubes().find( obj => obj.balls().length > 0 )!

				$mol_assert_not(game.tube_active()) // Активных нет

				tube.balls().map(obj => obj.color(0)) // Красим шары 

				game.tube_click(tube) // Кликаем по трубке
				$mol_assert_not(game.tube_active()) // Активных трубок все еще нет
			},

		} )
	}

Проверим что пустая трубка не активируется

	namespace $ {

		$mol_test( {
			//...

			'empty tube non activation'() {
				const game = new $hype_ballsort_game
				// Берем пустую трубку
				const tube = game.tubes().find( obj => obj.balls().length === 0 )!

				$mol_assert_not(game.tube_active())

				// Кликаем и убеждаемся что активных трубок нет
				game.tube_click(tube)
				$mol_assert_not(game.tube_active())
			},

		} )
	}

Проверим активацию трубок

Для этого нам надо:

  1. Создать инстанс игры

  2. Взять из него трубку с шариками и пустую трубку

  3. На всякий случай убедимся, что активных трубок нет

  4. Кликнем по заполненной трубке, проверим что она активна

  5. Кликнем по пустой трубке и проверим что активных трубок снова нет

  6. Кликнем на трубку, в которую положили шар и убедимся, что она активировалась

	namespace $ {

		$mol_test( {
			//...

			'tube activation'() {
				const game = new $hype_ballsort_game
				const tube_filled = game.tubes().find( obj => obj.balls().length > 0 )!
				const tube_empty = game.tubes().find( obj => obj.balls().length === 0 )!

				$mol_assert_not(game.tube_active())

				game.tube_click(tube_filled)
				$mol_assert_equal(tube_filled, game.tube_active())

				game.tube_click(tube_empty)
				$mol_assert_not(game.tube_active())

				game.tube_click(tube_empty)
				$mol_assert_equal(tube_empty, game.tube_active())
			},

		} )
	}

Попробуем переместить шар

	namespace $ {

		$mol_test( {
			//...
			
			'ball moving'() {
				const game = new $hype_ballsort_game

				// Берем заполненную и пустую трубки, а также шар который будет перемещаться
				const tube_filled = game.tubes().find( obj => obj.balls().length > 0 )!
				const tube_empty = game.tubes().find( obj => obj.balls().length === 0 )!
				const ball_moving = tube_filled.balls().at( -1 )!

				// Кликаем на заполненую трубку и на пусую
				game.tube_click( tube_filled )
				game.tube_click( tube_empty )

				// Убеждаемся что именно этот шар убыл из одной трубки и прибыл в другую
				$mol_assert_equal( tube_filled.balls().length, game.tube_size() - 1 )
				$mol_assert_not( tube_filled.balls().includes( ball_moving ) )

				$mol_assert_equal( tube_empty.balls().length, 1 )
				$mol_assert_ok( tube_empty.balls().includes( ball_moving ) )
			},


		} )
	}

Проверим что счетчик увеличивается при перемещении шара

	namespace $ {

		$mol_test( {
			//...
			
			'moves increment'() {
				const game = new $hype_ballsort_game
				const tube_filled = game.tubes().find( obj => obj.balls().length > 0 )!
				const tube_empty = game.tubes().find( obj => obj.balls().length === 0 )!

				game.tube_click( tube_filled )
				game.tube_click( tube_empty )
				$mol_assert_equal( game.moves(), 1 )
			},


		} )
	}

Проверим что игра заканчивается

	namespace $ {

		$mol_test( {
			//...
			
			'game finish'() {
				const game = new $hype_ballsort_game

				$mol_assert_not( game.finished() )

				game.balls().forEach( ball => ball.color( 0 ) )
				$mol_assert_ok( game.finished() )
			},


		} )
	}

Что дальше?

Продолжение с pixel perfect версткой будет в следующей части. А также напишем тестов для проверки всего приложения.

А пока можете разобраться в моделях/view-моделях других реализация:

Часть 2

По всем вопросам можно идти сюда.

Tags:
Hubs:
Total votes 10: ↑8 and ↓2+6
Comments22

Articles