Инфраструктура для интегрированного тестирования ПО

— No, Take the Fit Challenge Instead
— OK

Fit (fit.c2.com) — Framework for Integrated Test — это одновременно подход к спецификации ПО и доступный способ задания тестовых данных для него.

В идеале использование Fit должно позволить полностью отказаться от написания тестов вручную: все, что для них нужно, может извлекаться из Fit-спецификации. Впрочем, сами авторы идеи не пошли так далеко в своих планах, поскольку в показанных на сайте сценариях применения он используется лишь для задания тестовых данных для предварительно запрограммированных тестов. Этот подход, конечно, более прост в реализации и универсальнее[1], что позволяет справиться с различными граничными случаями, которые на практике могут встречаться в большом разнообразии.

Коротко, идея Fit заключается в том, чтобы сделать спецификацию программы «исполняемой». Достигается это за счет того, что в самих документах спецификации содержатся таблицы определенного формата, в которых описаны привязки для тестов. Интерпретатор Fit должен уметь найти эти таблицы, инициализировать тесты данными из них и выполнить определенные действия (основным из которых является проверка соответствия полученных результатов от вызова функций ожидаемым). В результате некоторые ячейки таблиц — ячейки ожидаемых результатов — должны окрасится в определенные цвета, что будет индикатором для всех участников процесса разработки: заказчиков и разработчиков, — того, как работает проверяемая функциональность. Таким образом достигается максимально возможная интеграция спецификации к программе с самой программой, о преимуществах чего, наверное, не стоит говорить.

Fit опирается на 3 основных формата задания привязок:

колонки (Column fixture) представляют собой таблицу, в которой каждой колонке соответствует имя аргумента или функции; в строках таблицы записываются варианты значений соответствующих аргументов и ожидаемый результат выполнения функций. Простой пример (№ 1):

Column-example
ab+()-()*()/()
326061.5
10110error
строчки (Row fixture) — это таблица, в которой записаны инициализационные значения для объекта или набора объектов сложной структуры какого-либо типа[2]. Пример — инициализация 2-х объектов пользовательского класса human с полями name, gender, date-of-birth, height, weight, family (Пример № 2):
Row-examplehuman



namegenderdate-of-birthheightweightfamily
«Alex»M«10.01.67»18567.3#h((wife . «Tamara») (children . («Maria» «Nadezhda»)))
«Nadezhda»F«9.12.02»13241.0nil
(Из таблицы мы должны извлечь набор из 2-х экземпляров класса. Последний аргумент — family — инициализируется хеш-таблицей)

действия (Action fixture) — в этой таблице записывается последовательность выполнения операций из набора заранее предопределенных[3]. Предполагается, что привязка Действия будет в основном использоваться для описания и проверки взаимодействия с интерфейсом программы (будь то консоль, GUI или веб-страница). Поэтому 4 первичных операции названы в соответствии с логикой этого домена (хотя они и имеют более общий смысл, который раскрыт ниже):

  • start object — все последующие операции перенаправляются в данный объект;
  • enter method argument — вызов функции c указанным аргументом (если функция относится к классу выбранного (операцией start) объекта, то вызов соответствующего метода;
  • press method — вызов функции без аргументов;
  • check method value — проверка соответствия результата выполнения функции заданному значению.
Оговорюсь, что в представленной реализации Fit (учитывая возможности Lisp, а также такие его особенности, как множественная диспетчиризация) эти операции трактуются в более обобщенном виде.

В целом, операции привязки Действие должны соответствовать модели тройной диспетчиризации:

  • первая колонка таблицы — для описания операции в рамках абстрактного пользовательского интерфейса;
  • вторая — конкретная часть интерфейса (в виде объекта или функции), с которой происходит взаимодействие;
  • третья — данные, которые должна интерпретировать сама программа.
Примером привязки действие может быть такая последовательность работы пользователя с веб-страницей (Пример № 3):
Action-exampleaction
pressFont
check*focus*Font-dialog
startFont-dialog
checkfont-size
enterfont-size11
checkfont-size11
pressok
check*focus*Main-window
(В этом надуманном примере тестируется работа с диалогом установки параметров шрифта. Первая проверка font-size с пустой третьей колонкой должна показать текущее значение).

Критика блочного тестирования (Unit Testing)

Тестирование сейчас стало одним из краеугольных камней «промышленного» процесса разработки ПО, а TDD — разработка, движимая тестами — одним из неотъемлемых терминов, характеризующий процесс разработки, претендующий на современность. Однако, при всем внимании, уделяемом вопросам тестирования, на мой взгляд, эта область пока не достигла зрелости, как и не выработано компромиссное понимание (или ряд альтернативных пониманий) того, что из себя должна представлять инфраструктура для тестирования (Test Framework)[4].

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

На основании своего, в основном, теоретического знакомства с Unit Test Framework’ами я пришел к выводу, что современный ad hoc стандарт реализации таких систем включает достаточно тривиальную функциональность:

  • проверка результатов на удовлетворения некоторым произвольно выбранным предикатам;
  • описание тестовых классов/функций, их наборов и иерархий, а также их исполнение;
  • загрузка тестовых данных (fixtures) из текстовых файлов (например, XML или YAML формата);
  • использование бутафорских объектов/функций (mocks, stubs);
  • некоторые библиотеки пытаются решать задачу интеграции с IDE.
При этом за рамками, как правило, остаются такие более трудоемкие и допускающие разнообразные реализации функции как:
  • тестирование на случайной выборке тестовых данных;
  • тестирование под нагрузкой;
  • механизмы расширения для создания доменно-специфических тестовых модулей[5];
  • и др.
В первую очередь, существующие подходы (особенно это выражено в библиотеке RSpec), нацелены на реализацию идеи спецификации функциональности в тестах. Но это приводит к дублированию усилий, потому что тесты, как правило, не могут служить адекватным методом спецификации для заказчика, поэтому они вынуждены отражать спецификацию, записанную другим (естественным) языком (возможно, с элементами формализации). В этом смысле как раз Fit позволяет минимизировать дублирование усилий за счет задания основной части данных для тестов в документе на естественном языке.

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

И, наконец, последнее направление использования программных тестов, которое не менее важно (и с которого, можно сказать, все начиналось) — проверка корректности работы программы[6]. Эта задача граничит с задачей отладки, поэтому полноценная тестовая инфраструктура должна предоставлять средства для автоматизации отладки, и здесь основное внимание должно уделяться вопросам фиксации результатов вычислений различными способами.

Таким образом, универсальная инфраструктура для тестирования программ должна отвечать таким требованиям:

  • ее ядро поддерживает базовые операции для автоматизации проверки результатов выполнения функций и отладки, описания тестов (с возможностью ссылок на одни тесты из других), исполнения тестов, определяет интерфейс для подключения модулей расширения. Ядро должно дать программисту инструментарий для подстройки инфраструктуры для своих нужд, а также создания расширений, таких как наборы доменно-специфических тестовых функций;
  • в качестве модулей расширения должны быть реализованы возможности:
    • задания тестовых данных разных форматов;
    • использования бутафорских объектов;
    • тестирования на случайной выборке данных;
    • тестирования под нагрузкой;
    • автоматизации регрессионного тестирования;
    • и др.
Не буду говорить о других языках (я лишь поверхностно знаком с реализацией таких известных библиотек, как JUnit и RSpec), но, что касается Common Lisp среды, то существующие библиотеки (перечень, небольшой обзор), находятся на таком уровне развития, что для меня более продуктивным путем оказалось написание собственной версии, которая бы имела перспективу удовлетворения всех требований перечисленных выше. Таким образом, появилась библиотека NUTS (Non-Unit Test Suite), в виде ядра и первого функционального модуля — Fit. Некоторые идеи при ее создании почерпнуты у Edi Weitz (п.6).

Реализация Fit на Lisp

Поддержка Fit реализована в качестве модуля расширения для библиотеки NUTS.

Оба сценариями использования Fit для тестирования:

  1. полностью автоматические тесты;
  2. задание тестовых данных;
поддерживаются в этой реализации:
  1. с помощью функции file-runner, соответствующей Fit-утилите FileRunner на других платформах (но не требующей предварительного определения тестов);
  2. с помощью специализированных на типе привязки (колонки, строчки, действия, действия реального времени) методов родовой функции interpret-fit-xture, используемых в реализации Fit-специфичного метода на родовой функции load-fixture библиотеки NUTS. На основе этой функции также работает макро def-fittest, уточняющий макро deftest из NUTS.
В целом, для создания библиотеки Fit на любом языке, необходима реализация как минимум следующих 4-х груп функциональности:
  • парсинг HTML-файлов для выделения таблиц Fit-привязок, а также вставка данных в заданные HTML-тэги (в этих таблицах);
  • адаптирование строковых данных, полученных из Fit-привязок, к типам данных языка;
  • создание механизма определения акторов для привязок типа Действие и задания базовых акторов;
  • рефлексия для определения членов класса или, как это сделано в данной реализации, списка аргументов функций.
В моей версии при реализации этих функций я попытался максимально воспользоваться возможностями, предоставляемыми самой средой Common Lisp.

Во-первых, для адаптации строковых данных к типам языка я полностью положился на встроенный парсер Lisp (read), который умеет обрабатывать все базовые типы языка (которые предполагают возможность прочтения; кроме хэш-таблиц), а также имеет мощный механизм расширения в форме reader macros. Как раз такой макр я добавил для введения возможности парсинга хэш-таблиц:

; a reader syntax for hash table like alists: #h([:test (test 'eql)] (key . val)*)
(set-dispatch-macro-character ## #h
 #'(lambda (stream subchar arg)
     (declare (ignore subchar)
	        (ignore arg))
     (let* ((sexp (read stream))
	      (test (unless (single sexp) (getf sexp :test)))
	      (kv-pairs (if test (cddr sexp) sexp))
	      (table (gensym)))
 	 `(let ((,table (make-hash-table :test (or ,test 'eql))))
	   (mapcar #'(lambda (cons)
		         (setf (gethash (car cons) ,table)
			         (cdr cons)))
	           ',kv-pairs)
	   ,table))))
Что касается механизмов рефлексии языка, то здесь было использовано 2 функции:
  • во-первых, функция operator-arglist, реализованная в библиотеке swank, являющейся составной частью платформо-независимой среды разработки SLIME, позволяет получить список аргументов любой функции (в том числе, макро). Затем этот список обрабатывается функцией slice-&-dice-arglist для выделения различных типов аргументов (которые у Lisp функции включают: обычные параметры, опциональные (&optional), по ключевым словам (&key), остаточные (&rest) и ряд других);
  • во-вторых, Fit-проверка на появления исключений поддерживается функцией class-subclasses, которая задействует мета-объектный протокол (MOP) Lisp для получения всех подклассов данного класса. Эта функция используется для того, чтобы учитывать возможность появления исключения, являющегося подклассом заданного в Fit привязке[7].
Парсинг HTML. В данном случае я решил отказаться от использования чего-то, наподобие YACC парсера и остановился на собственной реализации «наивного» алгоритма на основании конечного автомата.

Функция get-tag использует механизм множественных возвращаемых значений (multiple-values) и выдает содержание заданного тэга, а также дополнительные значения параметров тэга и пары начальная/конечная позиция тэга в строке. Эти значения группируются в списки, если функция вызывается без опционального параметра number (порядковый номер), если же параметр задан, то не нахождение тэга с таким порядковым номером считается ошибкой. При этом в результате вызова функции возвращаются тэги одного уровня вложенности (т.е. для случая вложенности одноименных тэгов, как в «<div>text <div>other text</div></div>», выдается только внешний div), что вносит определенную регуляризацию в обработку HTML-документа.

Вот примерная Fit-спецификация этой функции[8] (Пример № 4):

get-tag


tagsourcenumberget-tag()
«td»"Table below:<br />
<table cellpadding="10″>
<tr>
<td>first cell</td>
<td>second cell</td>
</tr><tr>
<td colspan=2>second row with not quite standard <td>inside</td></td>
</tr>
</table>«

(«first cell» «second cell» «second row with not quite standard <td>inside</td>»)
("" "" " colspan=2«)
((49 . 68) (68 . 88) (99 . 168))
2«second row with not quite standard <td>inside</td>»
" colspan=2″
(99 . 168)
3tag-not-found
«div»«<div>text <div>other text</div></div>»
(«text <div>other text</div>»)
(«„)
((0 . 37))
„hr“„<p>a <hr size=2> bcd</p>“
“»
" size=2″
(5 . 16)
«td»"<div class="first"><a href="cnn.com">cnn.com</a></div>«
nil
nil
nil
«some clear text»
nil
nil
nil
«!doctype»«<!DOCTYPE HTML PUBLIC »-//W3C//DTD HTML 4.0 Transitional//EN">
<HTML>
<HEAD>
<META HTTP-EQUIV="CONTENT-TYPE" CONTENT="text/html; charset=utf-8″>
<TITLE>FIT Self-test</TITLE>
</HEAD>
<!— comments —>
<BODY></BODY></HTML>"
0«"
«HTML PUBLIC \»-//W3C//DTD HTML 4.0 Transitional//EN\«"
(0 . 59)
«!—»0«"
«comments»
(181 . 197)
«hr»«<pa <hr size=2> bcd />»
error
«<p?>a <hr size=2> bcd</p?>»
error
«<p>a <hr size=2> bcd»
error
Кроме того, реализованы функции add-to-tag, позволяющие добавить данные, непосредственно внутрь HTML-файла в тэги, найденные с помощью функции get-tag. Функция colorize-td используется при обработке результата: сравнении его с данным ожидаемым результатом и формирования ячейки результирующей таблицы в соответствии с правилами Fit.

Наконец, что касается механизма определения акторов, то для этого используется хеш-таблица, в которую акторы помещаются как анонимные функции, созданные с помощью утилиты def-actor, а доступ к ним производится с помощью get-actor.

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

Вот, собственно, и весь краткий экскурс в реализацию Fit в среде Common Lisp, который оставляет за рамками решение трудностей, связанных с программной обработкой HTML (чему будет посвящена отдельная статья, предлагающая альтернативный выход), а также реализацию привязок типа Действия в реальном времени с использованием SBCL-специфичной библиотеки поддержки потоков sb-thread (для тестовых целей). Все это можно детально изучить в программном коде, находящемся на SourceFourge. В комплект также включены примеры и Fit-спецификация для низкоуровневых функций самой библиотеки (таких как get-tag).

Анализ результатов

Статья началась с того, что было принято предложение Джеймса Шора реализовать Fit на своем языке (в данном случае, Common Lisp).

При этом Шором были указаны такие критерии оценки качества языка, должные проявиться при реализации, как:

  • компактность;
  • читабельность;
  • поддерживаемость;
  • прикольность.
А также было указано, почему Fit является хорошим тестом для языка:
  • то, что это реальное ПО;
  • необходимость задействования разнообразных сфер: парсинг, ввод-вывод, рефлексия, интеграция с кодом третьих сторон;
  • то, что он проектировался, чтобы быть переносимым между реализациями;
  • небольшой объем — несколько дней работы.
Могу лишь отчасти дать отчет по указанным характеристикам:
  1. Всего эта реализация занимает около 900 строк кода (+ около 100 составляет актуальная версия библиотеки NUTS). Конечно, сравнение с более зрелыми версиями, которые могут включать специфические расширения, не совсем корректно (поэтому не буду сравнивать с версией на Java). Но, например, довольно компактная версия на Ruby, реализующая только первый сценарий применения Fit, составляет около 1700 строк кода.
  2. О читабельности и поддерживаемости собственного кода судить трудно. Скажу лишь, что все интерфейсные операции, предполагающие возможность расширения реализованы в виде родовых функций (generic functions). Кроме того, стоит выделить применение macrolet’ов (локальных макров) в самой громоздкой и трудной для понимания части кода — алгоритме для парсинга HTML, — с помощью чего удалось четко разделить три линии вычислений: базовый алгоритм, «хождение» по дереву тэгов и формирование результата, — а также соблюдение принципа поддержки функционального интерфейса (On Lisp, гл. 3.3).
  3. Что касается задействования разнообразных сфер программирования при написании кода, то, в принципе во всех из них можно было бы положиться на существующие средства языка, предоставляемые либо в виде стандартных функций (как то: read, MOP), либо в виде переносимых библиотек (swank, cl-ppcre). Единственная сфера, в которой я, как и многие другие разработчики Fit, создал собственное решение, является работа с HTML-текстом, наверное, потому, что это ключевая для работы всей Fit-инфраструктуры функция, которая должна быть достаточно гибкой (особенно, учитывая отсутствие единообразности в языке HTML, с которой постоянно нужно «бороться» при его автоматизированной обработке).
  4. На реализацию Fit у меня ушло не несколько дней, как утверждает автор, а около 20 дней разной загружнности: от пары часов в день до полноценного рабочего дня. Впрочем, это было довольно прикольно...
Кстати, у Fit уже была Common Lisp реализация, написанная в 2002 году (вместе с реализацией на Scheme), однако, по утверждениям самих разработчиков, она не завершена. Для сравнения, ее размер составляет около 1400 строк кода (в 1,5 раза больше). Отличиями является то, что в ней разработан собственный примитивный адаптор типов, вместо использования встроенного парсера языка, а также собственная функция исключения HTML сущностей (>, <, &) — моя реализация для этого полагается на библиотеку регулярных выражений cl-ppcre. Однако в ней не предоставляется специального механизма определения акторов. Привязки в ней определяются в качестве отдельных классов. Самым значительным отличием этой библиотеки, безусловно, является то, что она рассчитана только на первый сценарий использования Fit — в качестве отдельного приложения и не предоставляет возможностей для встраивания Fit-привязок в другие тесты. А сами тесты не определяются из Fit-документа, а являются предпрограммированными.

Примечания

  1. ^ Поскольку он опирается не исключительно на четко заданные форматы привязок (fixtures). (Хотя, ничего не мешает вводить свои собственные форматы, охватывающие любые варианты задания привязок для тестов. Вопрос только в том, чтобы не перейти грань за которой «язык» задания привязок станет сравнимым по сложности с базовым языком, на котором пишется ПО и тесты)
  2. ^ В первую очередь, разумеется, пользовательских классов. Однако, если инфраструктура претендует на полноту охвата для данного языка в ней должны быть реализованы варианты инициализации всех потенциально необходимых структур (Например, для Lisp это также: struct, plist...)
  3. ^ Естественно, список предопределенных операций может быть расширен программистом. В представленной реалиации для этого предназначена функция def-actor
  4. ^ В качестве зрелых областей, в которых существуют достаточно совершенные программные системы, можно назвать такие примеры из сферы поддержки разработки ПО, как:
    • интегрированные среды разработки
    • системы контроля версий программного кода
    • системы отслеживания багов
  5. ^ Упрощенный пример такого модуля — функционал для тестирования http-ответов в Rails.
  6. ^ До определенной степени, конечно. В целом, принято разделять верификацию и тестирование программ как формальный и неформальный методы проверки ее корректности.
  7. ^ Пример применения: последняя строка из примера № 1, где в результате деления на 0 будет поднята ошибка division-by-zero, а в поле ожидаемого результата записано просто error
  8. ^ В NUTS имена тестов отделены от других пространств имен, поэтому название теста идентичное имени функции не является ошибкой

Все про українське ІТ в телеграмі — підписуйтеся на канал DOU

👍ПодобаєтьсяСподобалось0
До обраногоВ обраному0
LinkedIn



10 коментарів

Підписатись на коментаріВідписатись від коментарів Коментарі можуть залишати тільки користувачі з підтвердженими акаунтами.

Конечно же, технология подходит не для всего. Для CMS, кажется, не подходит для CMS как продукта и хорошо годится для конечных продуктов, делающихся на основе какой-либо CMS.Acceptance — не для библиотек, а для конечных программ.

Fit и подобные просто созданы для финансового и подобных софтов, а вот есть ли у кого опыт промышленного использования автоматизации acceptance тестирования для более поведенческих, чем data- or calculation-driven програмных комплексов? С точки зрения эффективности. Например — CMS.

Исходников нет. Вернее есть, но дать не могу. Закрытая разработка. Почему так — для меня загадка. Можно смотреть на открытый PyFIT.Модулей с фикстурами — порядка сотни. Большинство фикстур до смешного простые и короткие. Исчезающе малая часть по сравнению с тысячами тестовых файлов (а учитывая многостраничность excel общее количество тестов уверенно переваливает за десять тысяч).Тестировщики ни одной строки кода не написали, зато бодро ваяют excel’ки.

@bialix, я нашел тут http://sourceforge.net/project... и архир сайта (домен заняли сквоттеры): http://web.archive.org/web/200.../

Андрей, дай ссылку на asgard, пожалуйста. А то гугля к германской мифологии отсылает.

@АндрейОтветил, спасибо. Возник еще вопрос: насколько этот Asgard универсальный. Т.е. какая доля кода там всё равно пишется разработчиками / тестировщиками, а какая используется в готовом виде? Само вытягивание данных из Excel довольно простая задача. Впрочем как и из таблиц (lxml.html).

Да, еще.Задание по внесению новой функциональности начинается, естественно, с обсуждения:, а что, собственно говоря требуется? Но к моменту deployed to QA у этих самых QA-тестировщиков должен уже быть готов fit тест на это дело.Если что-то «завалилось» — то тесты показывают, что именно.Конечно же, вся эта лавочка служит преимущественно для облегчения работы с теми ребятами, которые говорят «что должно получиться в конце концов».На чисто программерские заботы никак не отражается. Я, занимаясь преимущественно архитектурой проекта, с такими тестами редко имею дело. Но они реально помогают.

Могу рассказать, как дело обстоит у нас. Пишем софт для работы на финансовом рынке (очень похож на традиционный банковский).Программеры успешно кропают свои юнит-тесты и большего им не нужно (а если тестов мало — приходит начальник и в нескольких простых понятных фразах объясняет, что к чему).Юнит-тесты — действительно очень классная вешь.Но у нас есть отдел тестирования и мужи от бизнеса, которые на фондовом рынке собаку съели, а на Питоне писать не могут. Заявляют, что програмирование на Питоне для них — слишком сложная наука:) Выход — именно в том подходе, который описал Всеволод. Правда, мы используем не fit, а asgard — он делает практически то же (в какой-то степени клон), но в качестве входных данных понимает excel таблицы. Так проще нашим business guys и эти таблицы гораздо удобней редактировать. Все, что им нужно знать — как следует описывать данные для конкретной фикстуры и какие фикстуры нужно упоминать в тесте. Причем в детали реализации фикстур они не вникают — просят программистов сделать им «такую-то и такую-то» (на самом деле отражающую реальное поведение программы) — и все. Они запускают свой тест, видят, что не все отработало. Начинается общение с программерами (предметное). Проблема всегда обнаруживается (по убывающей) в некорректных исходных данных, в неправильной работе программы, в некорректной фикстуре. И быстро лечится. Все довольны. Отклик — очень быстрый. Регрессионные тесты — тщательно обрезанный набор fit тестов (у нас их зовут dev acceptance). И acceptance тысяч шесть, в то время как юнит-тестов «скромные» 2000 с копейками. Мы, программисты, просто не в состоянии написать такое плотное тестирование. Вдобавок полный прогон юнит-тестов занимает 7 минут, acceptance — 3 часа (они делают гораздо больше, работая с реальным источником данных).P.S. У нас нет «ручного тестирования» — его заменяет acceptance.P.P. S. Пока не перешел на эту работу — тоже не понимал, зачем fit. Сейчас не понимаю, как можно разрабатывать наш проект без него.Сергей, на вопрос ответил? Или нужны дополнительные пояснения?

очень хорошо.

Как-то это всё настолько круто, что аж не понятно зачем надо. С одной стороны руками написать тесты несложно (ну если язык человеческий). А с другой предлагается офигенно унифицированная система бла-бла-бла, ну как вот машина Тюринга тоже унифицированная, но как-то неубедительно смотрится в разработке. Судя по сайту Fit, задумка в том чтобы клиент сам писал тесты, и хоть стремление добавить ему головной боли похвально, мне кажется что было бы здорово сначала показать что такая система вообще нужна. Я думаю что существует очень мало тестов которые можно представить HTML таблицей, да еще и отдать на определение заказчику. За все время существования этой... идеи, был ли один задокументированый случай когда заказчик захотел таким воспользоваться и был потом сильно доволен?

Підписатись на коментарі