Как сократить time2market с помощью coding kata?

от автора

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

За время проекта два раза поменяли команду и заказчика.

Аудит процесса производства с точки зрения time2market (сроков от появления идеи до запуска в промышленное использование) показал, что задержки возникают на трех стадиях:

  • долго вносятся изменения в код

  • долго выполняется ручное тестирование

  • часто после тестирования код возвращается на доработку

Первопричиной низкого time2market являлось то, что команда не владела практикой coding kata, о которой я подробно расскажу в данной статье на примере задачи «Позолоченная роза«.

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

После применения приемов, описанных в этой статье, оставшиеся 95% проекта описанная выше команда реализовала и сдала за 6 месяцев вместо прогнозируемых 48 лет, что эквивалентно сокращению time2market в 96 раз.

Позолоченная роза: условия задачи для coding kata

Привет и добро пожаловать в команду Gilded Rose. Как вы знаете, мы небольшая гостиница в отличном месте в известном городе, которой управляет дружелюбный трактирщик по имени Эллисон. Мы также покупаем и продаем только лучшие товары. К сожалению, качество наших товаров постоянно ухудшается по мере приближения срока реализации. У нас есть система, которая обновляет наш инвентарь для нас. Он был разработан серьезным типом по имени Лирой, который перешел к новым приключениям. Ваша задача — добавить новую функцию в нашу систему, чтобы мы могли начать продавать новую категорию товаров. Сначала введение в нашу систему:

  • Все предметы имеют значение «продать в течение» (sell_in), которое обозначает количество дней, в течение которых мы должны продать предмет.

  • Все предметы имеют значение качества (quality), которое указывает, насколько ценен предмет.

  • В конце каждого дня наша система снижает оба значения для каждого элемента.

Довольно просто, правда? Ну вот тут становится интересно:

  • Как только срок продажи истек, качество ухудшается в два раза быстрее.

  • Качество предмета никогда не бывает отрицательным

  • «Выдержанный бри» (Aged Brie) на самом деле тем лучше, чем старше он становится.

  • Качество предмета никогда не превышает 50.

  • «Sulfuras», будучи легендарным предметом, никогда не продается и не теряет качества.

  • «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert), также как выдержанный бри (Aged Brie), повышаются в качестве по мере приближения значения sell_in: качество повышается на 2, если осталось 10 дней или меньше, и на 3, если осталось 5 дней или меньше, но после концерта качество падает до 0.

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

  • Качество «Сотворенных» (Conjured) предметов ухудшается в два раза быстрее, чем у обычных предметов.

Не стесняйтесь вносить любые изменения в метод update_quality и добавлять любой новый код, пока все работает правильно. Однако не изменяйте класс Item или свойство items, так как они принадлежат гоблину в углу, который нападет на вас и убьет вас одним выстрелом, поскольку он не верит в совместное владение кодом (вы можете сделать метод update_quality и свойство items статическими, если хотите, мы вас прикроем).

Исходный код: https://github.com/emilybache/GildedRose-Refactoring-Kata

#gilded_rose.py  class GildedRose(object):      def __init__(self, items):         self.items = items      def update_quality(self):         for item in self.items:             if item.name != "Aged Brie" and item.name != "Backstage passes to a TAFKAL80ETC concert":                 if item.quality > 0:                     if item.name != "Sulfuras, Hand of Ragnaros":                         item.quality = item.quality - 1             else:                 if item.quality < 50:                     item.quality = item.quality + 1                     if item.name == "Backstage passes to a TAFKAL80ETC concert":                         if item.sell_in < 11:                             if item.quality < 50:                                 item.quality = item.quality + 1                         if item.sell_in < 6:                             if item.quality < 50:                                 item.quality = item.quality + 1             if item.name != "Sulfuras, Hand of Ragnaros":                 item.sell_in = item.sell_in - 1             if item.sell_in < 0:                 if item.name != "Aged Brie":                     if item.name != "Backstage passes to a TAFKAL80ETC concert":                         if item.quality > 0:                             if item.name != "Sulfuras, Hand of Ragnaros":                                 item.quality = item.quality - 1                     else:                         item.quality = item.quality - item.quality                 else:                     if item.quality < 50:                         item.quality = item.quality + 1   class Item:     def __init__(self, name, sell_in, quality):         self.name = name         self.sell_in = sell_in         self.quality = quality      def __repr__(self):         return "%s, %s, %s" % (self.name, self.sell_in, self.quality)
#texttest_fixture.py  from __future__ import print_function  from gilded_rose import *  if __name__ == "__main__":     print ("OMGHAI!")     items = [              Item(name="+5 Dexterity Vest", sell_in=10, quality=20),              Item(name="Aged Brie", sell_in=2, quality=0),              Item(name="Elixir of the Mongoose", sell_in=5, quality=7),              Item(name="Sulfuras, Hand of Ragnaros", sell_in=0, quality=80),              Item(name="Sulfuras, Hand of Ragnaros", sell_in=-1, quality=80),              Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=15, quality=20),              Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=49),              Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=5, quality=49),              Item(name="Conjured Mana Cake", sell_in=3, quality=6),  # <-- :O             ]      days = 2     import sys     if len(sys.argv) > 1:         days = int(sys.argv[1]) + 1     for day in range(days):         print("-------- day %s --------" % day)         print("name, sellIn, quality")         for item in items:             print(item)         print("")         GildedRose(items).update_quality()

Проведите аудит процесса производства с точки зрения time2market

У Вас не будет доступа к команде, поэтому ограничьтесь только ревью артефактов:

  • требования

  • исходный код

  • тесты

Аудит процесса производства 1/4. Требования с точки зрения time2market

Изучите требования и запишите свое мнение о слабых и сильных сторонах требований с точки зрения time2market.

Для сравнения приведу свое субъективное мнение: существующие требования показались мне простыми и понятными, изучение требований заняло менее минуты. Возник вопрос как помечаются сотворенные предметы и я выдвинул гипотезу, что сотворенные предметы начинаются с последовательности «Conjured » перед названием предмета. Проверить гипотезу я не смог, поэтому принял ее на веру.

Аудит процесса производства 2/4. Проектирование и разработка с точки зрения time2market

Изучите код в файле gilded_rose.py и запишите свое мнение о слабых и сильных сторонах кода с точки зрения time2market.

#gilded_rose.py  class GildedRose(object):        ...        def update_quality(self):         for item in self.items:             if item.name != "Aged Brie" and item.name != "Backstage passes to a TAFKAL80ETC concert":                 if item.quality > 0:                     if item.name != "Sulfuras, Hand of Ragnaros":                         item.quality = item.quality - 1             else:                 if item.quality < 50:                     item.quality = item.quality + 1                     if item.name == "Backstage passes to a TAFKAL80ETC concert":                         if item.sell_in < 11:                             if item.quality < 50:                                 item.quality = item.quality + 1                         if item.sell_in < 6:                             if item.quality < 50:                                 item.quality = item.quality + 1             if item.name != "Sulfuras, Hand of Ragnaros":                 item.sell_in = item.sell_in - 1             if item.sell_in < 0:                 if item.name != "Aged Brie":                     if item.name != "Backstage passes to a TAFKAL80ETC concert":                         if item.quality > 0:                             if item.name != "Sulfuras, Hand of Ragnaros":                                 item.quality = item.quality - 1                     else:                         item.quality = item.quality - item.quality                 else:                     if item.quality < 50:                         item.quality = item.quality + 1      ...

Для сравнения приведу свое субъективное мнение: в методе update_quality я обнаружил менее 28 строк кода. Код показался мне запутанным. Любопытно, что слова «путать» и «путь» в русском языке похожи (хотя имеют разную этимологию).

Для определения количества путей в коде посчитайте цикломатическую сложность кода, используя пакет radon (https://radon.readthedocs.io/en/latest/commandline.html) и запишите свое мнение о сложности кода.

$ pip install radon $ radon cc -s gilded_rose.py   gilded_rose.py     M 8:4 GildedRose.update_quality - C (19)     C 3:0 GildedRose - C (11)     C 39:0 Item - A (2)     M 5:4 GildedRose.__init__ - A (1)     M 40:4 Item.__init__ - A (1)     M 45:4 Item.__repr__ - A (1)

Для сравнения приведу свое мнение: метод update_quality имеет цикломатическую сложность 19 единиц, то есть существует 19 путей, по которым может исполниться данный код. В мою голову относительно легко помещается 5 путей и этот код для меня сложный.

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

Запишите как много времени Вы потратили на изучение кода. У меня получилось около 20 минут.

Аудит процесса производства 3/4. Тестирования с точки зрения time2market

Изучите код в файле texttest_fixture.py и запишите свое мнение о слабых и сильных сторонах кода с точки зрения time2market.

#texttest_fixture.py  from __future__ import print_function  from gilded_rose import *  if __name__ == "__main__":     print ("OMGHAI!")     items = [              Item(name="+5 Dexterity Vest", sell_in=10, quality=20),              Item(name="Aged Brie", sell_in=2, quality=0),              Item(name="Elixir of the Mongoose", sell_in=5, quality=7),              Item(name="Sulfuras, Hand of Ragnaros", sell_in=0, quality=80),              Item(name="Sulfuras, Hand of Ragnaros", sell_in=-1, quality=80),              Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=15, quality=20),              Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=49),              Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=5, quality=49),              Item(name="Conjured Mana Cake", sell_in=3, quality=6),  # <-- :O             ]      days = 2     import sys     if len(sys.argv) > 1:         days = int(sys.argv[1]) + 1     for day in range(days):         print("-------- day %s --------" % day)         print("name, sellIn, quality")         for item in items:             print(item)         print("")         GildedRose(items).update_quality() 

Для сравнения приведу свое субъективное мнение: тесты проекта автоматизированы, но не являются автоматическими — то есть требуют участия тестировщика. В файле с тестами зафиксированы входные данные, написана утилита для запуска тест-кейсов, но отсутствуют ожидаемые результаты запуска. Тестировщик запускает утилиту и сравнивает результаты ее работы с известными ему результатами. Эта ручная работа увеличивает time2market.

Запустите тест, изучите выходные данные и попробуйте разобраться корректно ли текущий вариант кода реализовывает требования.

$ python texttest_fixture.py    OMGHAI! -------- day 0 -------- name, sellIn, quality +5 Dexterity Vest, 10, 20 Aged Brie, 2, 0 Elixir of the Mongoose, 5, 7 Sulfuras, Hand of Ragnaros, 0, 80 Sulfuras, Hand of Ragnaros, -1, 80 Backstage passes to a TAFKAL80ETC concert, 15, 20 Backstage passes to a TAFKAL80ETC concert, 10, 49 Backstage passes to a TAFKAL80ETC concert, 5, 49 Conjured Mana Cake, 3, 6  -------- day 1 -------- name, sellIn, quality +5 Dexterity Vest, 9, 19 Aged Brie, 1, 1 Elixir of the Mongoose, 4, 6 Sulfuras, Hand of Ragnaros, 0, 80 Sulfuras, Hand of Ragnaros, -1, 80 Backstage passes to a TAFKAL80ETC concert, 14, 21 Backstage passes to a TAFKAL80ETC concert, 9, 50 Backstage passes to a TAFKAL80ETC concert, 4, 50 Conjured Mana Cake, 2, 5

Для сравнения мое субъективное мнение: по-умолчанию утилита имитирует 2 дня работы системы для инвентаря из 9 предметов и тестировщику в этом случае нужно проверить 54 параметра. Некоторые тестовые случаи требуют имитации большего количества дней работы системы: например, для проверки того, что качество эликсира мангуста не падает ниже нуля, нужно эмулировать 8 дней работы системы, что приведет к четырехкратному увеличению нагрузки на тестировщика. Был обнаружен недостаток в реализации поведения программы для сотворенного пирожного маны — его качество убывало по одной единице в день, а не по две единицы в день как было указано в требованиях.

Запишите как много времени Вы потратили на запуск тестов. У меня получилось около 10 минут.

Аудит процесса производства 4/4. Рекомендациии по итогам аудита с целью снижения time2market

Запишите рекомендации по итогу изучения.

Для сравнения привожу свои рекомендации:

  1. Для сокращения time2market проектирования: провести рефакторинг кода update_quality в соответствии с принципами SOLID

  2. Для сокращения time2market разработки: снизить цикломатическую сложность кода до 5.

  3. Для сокращения time2market тестирования:

    3.1 написать отдельные тест-кейсы на каждое из требований

    3.2 исключить необходимость участия тестировщика

    3.3 обеспечить покрытие кода тестами не менее чем 90%

Рефакторинг кода 1/4. Автоматизация тестов

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

Замените код в test_gilded_rose.py на код из фрагмента ниже.

#test_gilded_rose.py  import pytest  def test_item_has_sell_in_attribute():     """     Все предметы имеют значение "продать в течение" (sell_in), которое обозначает количество дней, в течение которых мы должны продать предмет.     """     item = Item(name="Foo", sell_in=10, quality=1)     assert item.sell_in == 10  def test_item_has_quality_attribute():     """     Все предметы имеют значение качества (quality), которое указывает, насколько ценен предмет.     """     item = Item(name="Foo", sell_in=10, quality=1)     assert item.quality == 1  def test_system_decreases_quality_every_day():     """     В конце каждого дня наша система снижает оба значения для каждого элемента.     """     items = [Item(name="+5 Dexterity Vest", sell_in=10, quality=1),]     GildedRose(items).update_quality()     assert items[0].sell_in == 9     assert items[0].quality == 0

Запустите тесты с измерением процента покрытия кода.

$ pip install pytest $ pip install coverage  $ coverage run -m pytest test_gilded_rose.py && coverage report    ========================================================================== test session starts ========================================================================== platform darwin -- Python 3.10.9, pytest-7.2.1, pluggy-1.0.0 rootdir: /Users/vmihaylov/PycharmProjects/GildedRose-Refactoring-Kata/python collected 3 items                                                                                                                                                         test_gilded_rose.py ...                                                                                                                                           [100%]  =========================================================================== 3 passed in 0.00s =========================================================================== Name                  Stmts   Miss  Cover ----------------------------------------- gilded_rose.py           36     18    50% test_gilded_rose.py      13      0   100% ----------------------------------------- TOTAL                    49     18    63%

Покрытие кода gilded_rose.py составило 50%.

Добавьте тесты на остальные требования.

#test_gilded_rose.py  def test_when_sell_in_expired_quality_decreases_twice_as_fast():     """     Как только срок продажи истек, качество ухудшается в два раза быстрее.     """     items = [Item(name="+5 Dexterity Vest", sell_in=0, quality=10),]     GildedRose(items).update_quality()     assert items[0].sell_in == -1     assert items[0].quality == 8  def test_quality_never_becomes_negative():     """     Качество предмета никогда не бывает отрицательным     """     items = [Item(name="+5 Dexterity Vest", sell_in=1, quality=0),]     GildedRose(items).update_quality()     assert items[0].sell_in == 0     assert items[0].quality == 0  def test_aged_brie_becomes_better_after_sell_in():     """     «Выдержанный бри» (Aged Brie) на самом деле тем лучше, чем старше он становится.     """     items = [Item(name="Aged Brie", sell_in=10, quality=1),]     GildedRose(items).update_quality()     assert items[0].sell_in == 9     assert items[0].quality == 2  def test_item_quality_never_exceeds_50():     """     Качество предмета никогда не превышает 50.     """     items = [Item(name="Aged Brie", sell_in=10, quality=50),]     GildedRose(items).update_quality()     assert items[0].sell_in == 9     assert items[0].quality == 50  def test_sulfuras_never_sells_in_and_never_looses_quality():     """     "Sulfuras", будучи легендарным предметом, никогда не продается и не теряет качества.     """     items = [Item(name="Sulfuras, Hand of Ragnaros", sell_in=10, quality=80),]     GildedRose(items).update_quality()     assert items[0].sell_in == 10     assert items[0].quality == 80  def test_backstage_passes_increase_quality_by_2_if_10_to_6_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert) повышаются в качестве по мере приближения значения sell_in: качество повышается на 2, если осталось 10 дней или меньше.     """     items = [Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=6),]     GildedRose(items).update_quality()     assert items[0].sell_in == 9     assert items[0].quality == 8  def test_backstage_passes_increase_quality_by_3_if_5_to_1_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert), повышаются в качестве по мере приближения значения sell_in: качество повышается на 3, если осталось 5 дней или меньше.     """     items = [Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=5, quality=7),]     GildedRose(items).update_quality()     assert items[0].sell_in == 5     assert items[0].quality == 10  def test_backstage_passes_increase_quality_by_3_if_5_to_1_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert), после концерта качество падает до 0.     """     items = [Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7),]     GildedRose(items).update_quality()     assert items[0].sell_in == -1     assert items[0].quality == 0

Запустите тесты еще раз и измерьте покрытие кода gilded_rose.py тестами.

python % coverage run -m pytest test_gilded_rose.py && coverage report ========================================================================== test session starts ========================================================================== platform darwin -- Python 3.10.9, pytest-7.2.1, pluggy-1.0.0 rootdir: /Users/vmihaylov/PycharmProjects/GildedRose-Refactoring-Kata/python collected 10 items                                                                                                                                                        test_gilded_rose.py ..........                                                                                                                                    [100%]  ========================================================================== 10 passed in 0.01s =========================================================================== Name                  Stmts   Miss  Cover ----------------------------------------- gilded_rose.py           36      3    92% test_gilded_rose.py      53      4    92% ----------------------------------------- TOTAL                    89      7    92%

Покрытие кода тестами составило 92%.

Запишите сколько было потрачено времени на автоматизацию тестов. Для сравнения у меня ушло на это 40 минут.

Рефакторинг кода 2/4. Рефакторинг метода update_quality

По условиям задачи нельзя изменять код класса Item.

Для обхода этого ограничения создайте класс ItemEntity, реализующий следующие требования:

  • ItemEntity может быть создан из Item, при этом он копирует к себе поля Item

  • ItemEntity имеет атрибут conjured, устанавливаемый в True если название предмета начинается с «Conjured «

  • ItemEntity может быть преобразован в Item, при этом он копирует свои поля в Item

#test_gilded_rose.py  ...  def test_item_entity_copies_attributes_from_item():     """     ItemEntity может быть создан из Item, при этом он копирует к себе поля Item     """     item = Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7)     item_entity = ItemEntity(item=item)     assert item_entity.name == item.name     assert item_entity.sell_in == item.sell_in     assert item_entity.quality == item.quality  def test_item_entity_detects_conjured_item():     """     ItemEntity имеет атрибут conjured, устанавливаемый в True если название предмета начинается с "Conjured "     """     item = Item(name="Conjured Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7)     item_entity = ItemEntity(item=item)     assert item_entity.is_conjured  def test_item_entity_converts_to_item():     """     ItemEntity может быть преобразован в Item, при этом он копирует свои поля в Item     """     item = Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7)     item_entity = ItemEntity(item=item)     copied_item = item_entity.to_item()     assert copied_item.name == "Backstage passes to a TAFKAL80ETC concert"     assert copied_item.sell_in == 0     assert copied_item.quality == 7
#gilded_rose.py  ...  class ItemEntity:     def __init__(self, item):         name = item.name         self.name = name         self.sell_in = item.sell_in         self.quality = item.quality         self.is_conjured = name.startswith("Conjured ")

Перенесите код update_quality из класса GildedRose в класс ItemEntity

#gilded_rose.py  class GildedRose(object):        ...      def update_quality(self):         self.items = [ItemEntity(item=item).update_quality().to_item() for item in self.items]      ...  class ItemEntity:     ...      def update_quality(self):         if self.name != "Aged Brie" and self.name != "Backstage passes to a TAFKAL80ETC concert":             if self.quality > 0:                 if self.name != "Sulfuras, Hand of Ragnaros":                     self.quality = self.quality - 1         else:             if self.quality < 50:                 self.quality = self.quality + 1                 if self.name == "Backstage passes to a TAFKAL80ETC concert":                     if self.sell_in < 11:                         if self.quality < 50:                             self.quality = self.quality + 1                     if self.sell_in < 6:                         if self.quality < 50:                             self.quality = self.quality + 1         if self.name != "Sulfuras, Hand of Ragnaros":             self.sell_in = self.sell_in - 1         if self.sell_in < 0:             if self.name != "Aged Brie":                 if self.name != "Backstage passes to a TAFKAL80ETC concert":                     if self.quality > 0:                         if self.name != "Sulfuras, Hand of Ragnaros":                             self.quality = self.quality - 1                 else:                     self.quality = self.quality - self.quality             else:                 if self.quality < 50:                     self.quality = self.quality + 1

Измените тесты, чтобы они работали с экземпляром ItemEntity вместо GuildedRose

#test_gilded_rose.py  ...  def test_system_decreases_quality_every_day():     """     В конце каждого дня наша система снижает оба значения для каждого элемента.     """     item = ItemEntity(item=Item(name="+5 Dexterity Vest", sell_in=10, quality=1))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 0  def test_when_sell_in_expired_quality_decreases_twice_as_fast():     """     Как только срок продажи истек, качество ухудшается в два раза быстрее.     """     item = ItemEntity(Item(name="+5 Dexterity Vest", sell_in=0, quality=10))     item.update_quality()     assert item.sell_in == -1     assert item.quality == 8  def test_quality_never_becomes_negative():     """     Качество предмета никогда не бывает отрицательным     """     item = ItemEntity(Item(name="+5 Dexterity Vest", sell_in=1, quality=0))     item.update_quality()     assert item.sell_in == 0     assert item.quality == 0  def test_aged_brie_becomes_better_after_sell_in():     """     «Выдержанный бри» (Aged Brie) на самом деле тем лучше, чем старше он становится.     """     item = ItemEntity(Item(name="Aged Brie", sell_in=10, quality=1))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 2  def test_item_quality_never_exceeds_50():     """     Качество предмета никогда не превышает 50.     """     item = ItemEntity(Item(name="Aged Brie", sell_in=10, quality=50))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 50  def test_sulfuras_never_sells_in_and_never_looses_quality():     """     "Sulfuras", будучи легендарным предметом, никогда не продается и не теряет качества.     """     item = ItemEntity(Item(name="Sulfuras, Hand of Ragnaros", sell_in=10, quality=80))     item.update_quality()     assert item.sell_in == 10     assert item.quality == 80  def test_backstage_passes_increase_quality_by_2_if_10_to_6_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert) повышаются в качестве по мере приближения значения sell_in: качество повышается на 2, если осталось 10 дней или меньше.     """     item = ItemEntity(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=6))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 8  def test_backstage_passes_increase_quality_by_3_if_5_to_1_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert), повышаются в качестве по мере приближения значения sell_in: качество повышается на 3, если осталось 5 дней или меньше.     """     item = ItemEntity(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=5, quality=7))     item.update_quality()     assert item.sell_in == 5     assert item.quality == 10  def test_backstage_passes_increase_quality_by_3_if_5_to_1_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert), после концерта качество падает до 0.     """     item = ItemEntity(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7))     item.update_quality()     assert item.sell_in == -1     assert item.quality == 0  ...

Обратите внимание на повторяющуюся if-конструкцию ниже в методе update_quality и вынесите ее в отдельный метод increase_quality.

#gilded_rose.py  class ItemEntity:     ...      def update_quality(self):        ...                 if self.quality < 50:                     self.quality = self.quality + 1       ...
#gilded_rose.py  class ItemEntity:     ...      def increase_quality(self):         if self.quality < 50:             self.quality = self.quality + 1      ...

Обратите внимание на повторяющуюся конструкцию уменьшения качества и вынесите ее в отдельный метод decrease_quality_if_not_sulfuras()

#gilded_rose.py  class ItemEntity:     ...      def update_quality(self):        ...           if self.quality > 0:               if self.name != "Sulfuras, Hand of Ragnaros":                   self.quality = self.quality - 1       ...
#gilded_rose.py  class ItemEntity:     ...      def decrease_quality_if_not_sulfuras(self):       if self.quality > 0:           if self.name != "Sulfuras, Hand of Ragnaros":               self.quality = self.quality - 1                    ...

Название метода decrease_quality_if_not_sulfuras подсказывает нам, что его реализация должна отличаться для предметов Sulfuras. Реализуйте это поведение с помощью дочернего класса.

Создайте класс Sulfuras и перенесите в него логику уменьшения качества (в данном случае — эта логика состоит в не делании ничего, то есть в конструкции pass).

#gilded_rose.py  ...  class Sulfuras(ItemEntity):     def decrease_quality(self):         pass  ...

Создайте класс EntityFactory, который будет создавать правильный объект в зависимости от его наименования.

#gilded_rose.py  ...  class EntityFactory:     def create(item):         if item.name == "Sulfuras, Hand of Ragnaros":             return Sulfuras(item=item)         else:             return ItemEntity(item=item)  ...

Поменяйте код для использования EntityFactory вместо прямого создания ItemEntity в коде GildedRose и в тестах.

#gilded_rose.py  ...  class GildedRose(object):      ...      def update_quality(self):         self.items = [EntityFactory.create(item=item).update_quality().to_item() for item in self.items]      ...
#test_gilded_rose.py  ... from gilded_rose import Item, GildedRose, ItemEntity, Sulfuras, EntityFactory ...  def test_system_decreases_quality_every_day():     """     В конце каждого дня наша система снижает оба значения для каждого элемента.     """     item = EntityFactory.create(item=Item(name="+5 Dexterity Vest", sell_in=10, quality=1))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 0  def test_when_sell_in_expired_quality_decreases_twice_as_fast():     """     Как только срок продажи истек, качество ухудшается в два раза быстрее.     """     item = EntityFactory.create(Item(name="+5 Dexterity Vest", sell_in=0, quality=10))     item.update_quality()     assert item.sell_in == -1     assert item.quality == 8  def test_quality_never_becomes_negative():     """     Качество предмета никогда не бывает отрицательным     """     item = EntityFactory.create(Item(name="+5 Dexterity Vest", sell_in=1, quality=0))     item.update_quality()     assert item.sell_in == 0     assert item.quality == 0  def test_aged_brie_becomes_better_after_sell_in():     """     «Выдержанный бри» (Aged Brie) на самом деле тем лучше, чем старше он становится.     """     item = EntityFactory.create(Item(name="Aged Brie", sell_in=10, quality=1))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 2  def test_item_quality_never_exceeds_50():     """     Качество предмета никогда не превышает 50.     """     item = EntityFactory.create(Item(name="Aged Brie", sell_in=10, quality=50))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 50  def test_sulfuras_never_sells_in_and_never_looses_quality():     """     "Sulfuras", будучи легендарным предметом, никогда не продается и не теряет качества.     """     item = EntityFactory.create(Item(name="Sulfuras, Hand of Ragnaros", sell_in=10, quality=80))     item.update_quality()     assert item.sell_in == 10     assert item.quality == 80  def test_backstage_passes_increase_quality_by_2_if_10_to_6_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert) повышаются в качестве по мере приближения значения sell_in: качество повышается на 2, если осталось 10 дней или меньше.     """     item = EntityFactory.create(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=6))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 8  def test_backstage_passes_increase_quality_by_3_if_5_to_1_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert), повышаются в качестве по мере приближения значения sell_in: качество повышается на 3, если осталось 5 дней или меньше.     """     item = EntityFactory.create(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=5, quality=7))     item.update_quality()     assert item.sell_in == 5     assert item.quality == 10  def test_backstage_passes_increase_quality_by_3_if_5_to_1_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert), после концерта качество падает до 0.     """     item = EntityFactory.create(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7))     item.update_quality()     assert item.sell_in == -1     assert item.quality == 0  def test_sulfuras_produced_by_factory():     """     Предметы "Sulfuras, Hand of Ragnaros" производят сущность Sulfuras     """     sulfuras = EntityFactory.create(Item(name="Sulfuras, Hand of Ragnaros", sell_in=10, quality=80))     assert isinstance(sulfuras, Sulfuras)  ...

Обратите внимание на конструкцию уменьшения срока годности sell_in и перенесите ее в отдельный метод decrease_sell_in.

#gilded_rose.py  ...  class ItemEntity:      ...      def update_quality(self):        ...              if self.name != "Sulfuras, Hand of Ragnaros":         self.sell_in = self.sell_in - 1      ... 

Сделайте разные реализации decrease_sell_in для обычных предметов и Sulfuras.

#gilded_rose.py  class ItemEntity:      ...      def decrease_sell_in(self):         self.sell_in = self.sell_in - 1      ...  ...          class Sulfuras(ItemEntity):     ...      def decrease_sell_in(self):         pass      ...

Добавьте тесты для классов «Aged Brie» и «Backstage passes to a TAFKAL80ETC concert», создайте эти классы и добавьте в фабрику логику для создания правильных объектов в зависимости от названия предмета.

#test_gilded_rose.py  ...  from gilded_rose import Item, GildedRose, ItemEntity, Sulfuras, EntityFactory, AgedBrie, BackstagePasses  ...  def test_aged_brie_produced_by_factory():     """     Предметы "Aged Brie" производят сущность AgedBrie     """     aged_brie = EntityFactory.create(Item(name="Aged Brie", sell_in=10, quality=80))     assert isinstance(aged_brie, AgedBrie)  def test_backstage_passes_produced_by_factory():     """     Предметы "Backstage passes to a TAFKAL80ETC concert" производят сущность BackstagePasses     """     backstage_passes = EntityFactory.create(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=80))     assert isinstance(backstage_passes, BackstagePasses)  ...
#gilded_rose.py  ... class AgedBrie(ItemEntity):     pass  class BackstagePasses(ItemEntity):     pass  class EntityFactory:     def create(item):         if item.name == "Aged Brie":             return AgedBrie(item=item)         elif item.name == "Backstage passes to a TAFKAL80ETC concert":             return BackstagePasses(item=item)         elif item.name == "Sulfuras, Hand of Ragnaros":             return Sulfuras(item=item)         else:             return ItemEntity(item=item)  ...

Обратите внимание на то, что код улучшения качества в update_quality очень похож на код increase_quality, но для Backstage passes он дополнительно выполняет улучшение качества для разных sell_in.

#gilded_rose.py  ...  class ItemEntity:      ...      def increase_quality(self):       if self.quality < 50:           self.quality = self.quality + 1                ...      def update_quality(self):        ...              if self.quality < 50:           self.quality = self.quality + 1           if self.name == "Backstage passes to a TAFKAL80ETC concert":               if self.sell_in < 11:                   self.increase_quality()               if self.sell_in < 6:                   self.increase_quality()      ...      

Перенесите строки 19-25 выше в метод increase_quality класса BackstagePasses, а в теле update_quality оставьте вызов increase_quality.

#gilded_rose.py  ...  class BackstagePasses(ItemEntity):     def increase_quality(self):         super().increase_quality()          if self.sell_in < 11:             super().increase_quality()         if self.sell_in < 6:             super().increase_quality()  class ItemEntity:        ...      def increase_quality(self):         if self.quality < 50:             self.quality = self.quality + 1      ...      def update_quality(self):         if self.name != "Aged Brie" and self.name != "Backstage passes to a TAFKAL80ETC concert":             self.decrease_quality()         else:             self.increase_quality()          self.decrease_sell_in()          if self.sell_in < 0:             if self.name != "Aged Brie":                 if self.name != "Backstage passes to a TAFKAL80ETC concert":                     self.decrease_quality()                 else:                     self.quality = self.quality - self.quality             else:                 self.increase_quality()          return self      ...

Обратите внимание на конструкцию уменьшения качества для Aged Brie и Backstage passes.

#gilded_rose.py  ...  class ItemEntity:     ...      def update_quality(self):         if self.name != "Aged Brie" and self.name != "Backstage passes to a TAFKAL80ETC concert":             self.decrease_quality()         else:             self.increase_quality()          ...

Создайте метод change_quality, который для Aged Brie и Backstage passes будет увеличивать качество, а для остальных сущностей — уменьшать.

#gilded_rose.py  ...  class ItemEntity:     ...      def change_quality(self):         self.decrease_quality()      ...      def update_quality(self):         self.change_quality()          ...      ...  class AgedBrie(ItemEntity):     ...          def change_quality(self):         self.increase_quality()      ...  class BackstagePasses(ItemEntity):      ...          def change_quality(self):         self.increase_quality()      ...

Обратите внимание на фрагмент кода с изменением качества после окончания срока годности. Перенесите его в отдельный метод change_quality_after_sell_in и сделайте отдельные реализации для Aged Brie и Backstage Passes.

#gilded_rose.py  ...  class ItemEntity:     ...      def update_quality(self):         ...          if self.sell_in < 0:           if self.name != "Aged Brie":               if self.name != "Backstage passes to a TAFKAL80ETC concert":                   self.decrease_quality()               else:                   self.quality = self.quality - self.quality           else:               self.increase_quality()          ...      ...
#gilded_rose.py  ...  class ItemEntity:     ...       def change_quality_after_sell_in(self):         self.decrease_quality()      def update_quality(self):         self.change_quality()          self.decrease_sell_in()          if self.sell_in < 0:             self.change_quality_after_sell_in()          return self      ...  class AgedBrie(ItemEntity):     ...      def change_quality_after_sell_in(self):         self.increase_quality()  class BackstagePasses(ItemEntity):     ...      def change_quality_after_sell_in(self):         self.quality = 0

Рефакторинг кода 3/4. Проверьте цикломатическую сложность

$ radon cc -s gilded_rose.py gilded_rose.py     C 56:0 EntityFactory - A (5)     M 57:4 EntityFactory.create - A (4)     C 1:0 GildedRose - A (3)     C 74:0 BackstagePasses - A (3)     M 81:4 BackstagePasses.increase_quality - A (3)     M 6:4 GildedRose.update_quality - A (2)     C 9:0 Item - A (2)     C 18:0 ItemEntity - A (2)     M 26:4 ItemEntity.increase_quality - A (2)     M 30:4 ItemEntity.decrease_quality - A (2)     M 43:4 ItemEntity.update_quality - A (2)     C 67:0 AgedBrie - A (2)     C 89:0 Sulfuras - A (2)     M 3:4 GildedRose.__init__ - A (1)     M 10:4 Item.__init__ - A (1)     M 15:4 Item.__repr__ - A (1)     M 19:4 ItemEntity.__init__ - A (1)     M 34:4 ItemEntity.decrease_sell_in - A (1)     M 37:4 ItemEntity.change_quality - A (1)     M 40:4 ItemEntity.change_quality_after_sell_in - A (1)     M 53:4 ItemEntity.to_item - A (1)     M 68:4 AgedBrie.change_quality - A (1)     M 71:4 AgedBrie.change_quality_after_sell_in - A (1)     M 75:4 BackstagePasses.change_quality - A (1)     M 78:4 BackstagePasses.change_quality_after_sell_in - A (1)     M 90:4 Sulfuras.decrease_quality - A (1)     M 93:4 Sulfuras.decrease_sell_in - A (1)

Все методы GuildedRose теперь имеют цикломатическую сложность менее 5.

Рефакторинг кода 4/4. Добавьте новое требование

Напишите тест для реализации требования и скорректируйте ItemEntity:

  • Качество «Сотворенных» (Conjured) предметов ухудшается в два раза быстрее, чем у обычных предметов.

#test_gilded_rose.py  ...  def test_conjured_items_quality_decreases_twice_as_fast():     """     Качество «Сотворенных» (Conjured) предметов ухудшается в два раза быстрее, чем у обычных предметов.     """     item = EntityFactory.create(item=Item(name="Conjured +5 Dexterity Vest", sell_in=10, quality=5))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 3  def test_conjured_aged_brie_produce_aged_brie_by_factory():     """     Предметы "Conjured Aged Brie" производят сущность AgedBrie     """     aged_brie = EntityFactory.create(Item(name="Conjured Aged Brie", sell_in=10, quality=80))     assert isinstance(aged_brie, AgedBrie)

Измерьте сколько времени ушло на добавление требования и тестирование в код, прошедший coding kata. У меня это заняло 10 минут.

Измерьте потраченное время на рефакторинг и посчитайте сколько раз Вы запускали тесты. Для сравнения у меня ушло 2 часа, а тесты запускались около 100 раз.

Финальный листинг

#gilded_rose.py  class GildedRose(object):      def __init__(self, items):         self.items = items      def update_quality(self):         self.items = [EntityFactory.create(item=item).update_quality().to_item() for item in self.items]  class Item:     def __init__(self, name, sell_in, quality):         self.name = name         self.sell_in = sell_in         self.quality = quality      def __repr__(self):         return "%s, %s, %s" % (self.name, self.sell_in, self.quality)  class ItemEntity:     def __init__(self, item):         name = item.name         self.name = name         self.sell_in = item.sell_in         self.quality = item.quality         self.is_conjured = name.startswith("Conjured ")      def increase_quality(self):         if self.quality < 50:             self.quality = self.quality + 1      def decrease_quality(self):         if self.quality > 0:             self.quality = self.quality - 1          if self.is_conjured:             if self.quality > 0:                 self.quality = self.quality - 1      def decrease_sell_in(self):         self.sell_in = self.sell_in - 1      def change_quality(self):         self.decrease_quality()      def change_quality_after_sell_in(self):         self.decrease_quality()      def update_quality(self):         self.change_quality()          self.decrease_sell_in()          if self.sell_in < 0:             self.change_quality_after_sell_in()          return self      def to_item(self):         return Item(name=self.name, sell_in=self.sell_in, quality=self.quality)  class EntityFactory:     def remove_conjured_if_exists(name):         if name.startswith("Conjured "):             return name[9:]         else:             return name      def create(item):         stripped_name = EntityFactory.remove_conjured_if_exists(item.name)          if stripped_name == "Aged Brie":             return AgedBrie(item=item)         elif stripped_name == "Backstage passes to a TAFKAL80ETC concert":             return BackstagePasses(item=item)         elif stripped_name == "Sulfuras, Hand of Ragnaros":             return Sulfuras(item=item)         else:             return ItemEntity(item=item)  class AgedBrie(ItemEntity):     def change_quality(self):         self.increase_quality()      def change_quality_after_sell_in(self):         self.increase_quality()  class BackstagePasses(ItemEntity):     def change_quality(self):         self.increase_quality()      def change_quality_after_sell_in(self):         self.quality = 0      def increase_quality(self):         super().increase_quality()          if self.sell_in < 11:             super().increase_quality()         if self.sell_in < 6:             super().increase_quality()  class Sulfuras(ItemEntity):     def decrease_quality(self):         pass      def decrease_sell_in(self):         pass  
#test_gilded_rose.py  import pytest  from gilded_rose import Item, GildedRose, ItemEntity, Sulfuras, EntityFactory, AgedBrie, BackstagePasses  def test_item_has_sell_in_attribute():     """     Все предметы имеют значение "продать в течение" (sell_in), которое обозначает количество дней, в течение которых мы должны продать предмет.     """     item = Item(name="Foo", sell_in=10, quality=1)     assert item.sell_in == 10  def test_item_has_quality_attribute():     """     Все предметы имеют значение качества (quality), которое указывает, насколько ценен предмет.     """     item = Item(name="Foo", sell_in=10, quality=1)     assert item.quality == 1  def test_system_decreases_quality_every_day():     """     В конце каждого дня наша система снижает оба значения для каждого элемента.     """     item = EntityFactory.create(item=Item(name="+5 Dexterity Vest", sell_in=10, quality=1))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 0  def test_when_sell_in_expired_quality_decreases_twice_as_fast():     """     Как только срок продажи истек, качество ухудшается в два раза быстрее.     """     item = EntityFactory.create(Item(name="+5 Dexterity Vest", sell_in=0, quality=10))     item.update_quality()     assert item.sell_in == -1     assert item.quality == 8  def test_quality_never_becomes_negative():     """     Качество предмета никогда не бывает отрицательным     """     item = EntityFactory.create(Item(name="+5 Dexterity Vest", sell_in=1, quality=0))     item.update_quality()     assert item.sell_in == 0     assert item.quality == 0  def test_aged_brie_becomes_better_after_sell_in():     """     «Выдержанный бри» (Aged Brie) на самом деле тем лучше, чем старше он становится.     """     item = EntityFactory.create(Item(name="Aged Brie", sell_in=10, quality=1))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 2  def test_item_quality_never_exceeds_50():     """     Качество предмета никогда не превышает 50.     """     item = EntityFactory.create(Item(name="Aged Brie", sell_in=10, quality=50))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 50  def test_sulfuras_never_sells_in_and_never_looses_quality():     """     "Sulfuras", будучи легендарным предметом, никогда не продается и не теряет качества.     """     item = EntityFactory.create(Item(name="Sulfuras, Hand of Ragnaros", sell_in=10, quality=80))     item.update_quality()     assert item.sell_in == 10     assert item.quality == 80  def test_backstage_passes_increase_quality_by_2_if_10_to_6_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert) повышаются в качестве по мере приближения значения sell_in: качество повышается на 2, если осталось 10 дней или меньше.     """     item = EntityFactory.create(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=6))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 8  def test_backstage_passes_increase_quality_by_3_if_5_to_1_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert), повышаются в качестве по мере приближения значения sell_in: качество повышается на 3, если осталось 5 дней или меньше.     """     item = EntityFactory.create(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=5, quality=7))     item.update_quality()     assert item.sell_in == 5     assert item.quality == 10  def test_backstage_passes_increase_quality_by_3_if_5_to_1_days_left():     """     «Проходы за кулисы» (Backstage passes to a TAFKAL80ETC concert), после концерта качество падает до 0.     """     item = EntityFactory.create(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7))     item.update_quality()     assert item.sell_in == -1     assert item.quality == 0  def test_item_entity_copies_attributes_from_item():     """     ItemEntity может быть создан из Item, при этом он копирует к себе поля Item     """     item = Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7)     item_entity = ItemEntity(item=item)     assert item_entity.name == item.name     assert item_entity.sell_in == item.sell_in     assert item_entity.quality == item.quality  def test_item_entity_detects_conjured_item():     """     ItemEntity имеет атрибут conjured, устанавливаемый в True если название предмета начинается с "Conjured "     """     item = Item(name="Conjured Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7)     item_entity = ItemEntity(item=item)     assert item_entity.is_conjured  def test_item_entity_converts_to_item():     """     ItemEntity может быть преобразован в Item, при этом он копирует свои поля в Item     """     item = Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=0, quality=7)     item_entity = ItemEntity(item=item)     copied_item = item_entity.to_item()     assert copied_item.name == "Backstage passes to a TAFKAL80ETC concert"     assert copied_item.sell_in == 0     assert copied_item.quality == 7  def test_sulfuras_produced_by_factory():     """     Предметы "Sulfuras, Hand of Ragnaros" производят сущность Sulfuras     """     sulfuras = EntityFactory.create(Item(name="Sulfuras, Hand of Ragnaros", sell_in=10, quality=80))     assert isinstance(sulfuras, Sulfuras)  def test_aged_brie_produced_by_factory():     """     Предметы "Aged Brie" производят сущность AgedBrie     """     aged_brie = EntityFactory.create(Item(name="Aged Brie", sell_in=10, quality=80))     assert isinstance(aged_brie, AgedBrie)  def test_backstage_passes_produced_by_factory():     """     Предметы "Backstage passes to a TAFKAL80ETC concert" производят сущность BackstagePasses     """     backstage_passes = EntityFactory.create(Item(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=80))     assert isinstance(backstage_passes, BackstagePasses)  def test_conjured_items_quality_decreases_twice_as_fast():     """     Качество «Сотворенных» (Conjured) предметов ухудшается в два раза быстрее, чем у обычных предметов.     """     item = EntityFactory.create(item=Item(name="Conjured +5 Dexterity Vest", sell_in=10, quality=5))     item.update_quality()     assert item.sell_in == 9     assert item.quality == 3  def test_conjured_aged_brie_produce_aged_brie_by_factory():     """     Предметы "Conjured Aged Brie" производят сущность AgedBrie     """     aged_brie = EntityFactory.create(Item(name="Conjured Aged Brie", sell_in=10, quality=80))     assert isinstance(aged_brie, AgedBrie)

Заключение

Coding kata для 30 строк кода и покрытие их тестами заняли около 3 часов.

В процессе выполнения coding kata автоматические тесты запускались более 100 раз.

Итоговый вариант имеет цикломатическую сложность 5 вместо 19.

Внесение нового требования в код заняло менее 10 минут.

Применение данного подхода в промышленном масштабе на практике показало успешное сокращение time2market производства до 96 раз.

Спасибо, что дочитали до этого места. О других причинах высокого time2market можно узнать здесь.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какие у вас есть трудности с time2market в процессе производства?
83.33% У нас плохие требования и мы долго в них разбираемся 5
33.33% У нас плохой код и мы долго его правим 2
66.67% У нас ручные тесты и мы долго тестируем 4
16.67% У нас все отлично 1
Проголосовали 6 пользователей. Воздержались 3 пользователя.

ссылка на оригинал статьи https://habr.com/ru/post/718746/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *