100% покрытия не поймали единственный баг, который был важен

от автора

Symfony в версии 8.1 выпустила компонент TUI — дерево виджетов, движок раскладки и рендерер для полноэкранных терминальных приложений на PHP.

Сразу оговорюсь, иначе спросят в первом же комментарии: php-tui/php-tui существует давно, он зрелый и по загрузкам опережает компонент Symfony на два порядка. Я взял symfony/tui не потому, что он лучше, а потому что проект и так на Symfony, и тащить второй фреймворк ради одной консольной команды не хотелось.

Девятого августа, когда я брался за работу, у symfony/tui не было ни одного зависимого пакета на Packagist. Страницы документации тоже не было — она и сейчас существует только в виде двух открытых PR.

Мне нужна была таблица: прокрутка, сортировка, фильтр по массиву строк. Я её написал. Про сам пакет тут будет одно предложение в конце; интереснее то, что эта работа показала про мои же тесты.

Баг, которого не видели 92 теста

К моменту тега 0.1.0 у виджета было 92 теста и 100% построчного покрытия src/. PHPStan на level max без baseline. Я был доволен.

Потом я вставил виджет в реальную artisan-команду в своём проекте, открыл в небольшом окне терминала — и шапка таблицы уехала за верхнюю границу экрана.

Виджет вообще не читал высоту, которую ему выдали. При любом размере терминала он возвращал своё настроенное число строк: при stty rows 10 это 17 строк — шапка, пятнадцать рядов данных и индикатор прокрутки — в окно, где помещается десять. Движок раскладки добивает короткий вывод пустыми строками, но длинный не обрезает, поэтому лишние строки вытолкнули верх кадра за экран.

Почему ни один тест этого не поймал. Все тесты рендера выглядели так:

$lines = $table->render(new RenderContext(80, 24));$this->assertSame('Package        Downloads', $lines[0]);$this->assertCount(17, $lines);

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

Я тщательно, со всех сторон, при стопроцентном покрытии проверил код против своего же мнения о нём.

Покрытие считает выполненные строки. О предположениях, которые вы ни разу не поставили под сомнение, оно не говорит ничего.

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

Скучный догфудинг работает лучше

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

Тексты пустых состояний были зашиты по-английски. В интерфейсе, целиком русском, фильтр без совпадений выводил посреди экрана No matches, а класс был final — наследником не переопределить.

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

Чтобы написать в статусной строке «сортировка: Заезд ↓», вызывающему коду приходилось второй раз конструировать колонки и строить свою карту «ключ → заголовок»: виджет свои колонки знал, но наружу не отдавал. Чтобы показать число подходящих строк — ловить событие фильтра и держать счётчик в переменной по ссылке.

Ничего экзотического. Всё это находится в первый час использования и не находится написанием новых юнит-тестов — потому что это дефекты не кода, а формы API, а тесты написаны против той же самой формы.

Дальше потёк сам компонент

Когда свои баги кончились, нашлись два чужих.

Первый. При выходе любое приложение на компоненте оставляло после себя пустую строку, а достаточно высокий кадр терял верхнюю строку из-за прокрутки — даже когда помещался целиком. Tui::stop() уводит курсор в конец отрисованного содержимого и пишет \r\n. Арифметика была такой:

$lineDiff = $state['line_count'] - $state['cursor_row'];

line_count — это количество, cursor_row — индекс с нуля. Их разность уже ставит курсор на строку ниже кадра, а следующий \r\n сдвигает ещё на одну. Кадр из пяти строк в терминале на шесть рядов помещается вместе с приглашением шелла — и всё равно терял первую строку.

Второй. Псевдотерминал, которому не выставили размер окна, отдаёт на stty size строку 0 0. Класс терминала принимал это за настоящий размер, рендерер зажимал область контента снизу единицей, а проверка ширины затем отвергала этот единственный столбец при нуле доступных. Приложение падало необработанным исключением с кодом выхода 255.

Это не экзотика: так ведут себя обёртки над pty.fork(), часть CI-харнессов и скрипты снятия скриншотов. Я наткнулся на это, когда снимал скриншот собственного демо.

У второго бага готовый ответ лежал в том же фреймворке. Console\Terminal давно трактует нуль как «размер неизвестен»:

return self::$width ?: 80;

В терминале TUI тот же фолбэк написан через ??, поэтому нуль проходил насквозь. Разница в один символ.

Оба исправления смержены в 8.1 и вышли в 8.1.5. Написание описаний заняло больше времени, чем сами патчи, и это правильное соотношение: диф в одну строку без объяснения — это догадка, а диф в одну строку с воспроизводящим тестом и абзацем о том, почему арифметика не сходится, — это отчёт, с которым можно работать.

Третий баг я нашёл, когда его уже починили

На прошлых выходных вышла 8.1.5, и я сел обновлять пакет. Тестов к тому моменту стало 121, покрытие всё те же 100%, прогон зелёный, менять нечего.

Но раз уж полез, сравнил поведение 8.1.4 и 8.1.5 напрямую, функция к функции. И наткнулся на разницу, которой не ждал.

Таб. Обычный \t в ячейке.

Компонент считает таб тремя клетками — у него есть публичная константа AnsiUtils::TAB_WIDTH = 3 с комментарием, что все, кто меряет или режет текст, обязаны с ней согласовываться. А функция обрезки до 8.1.4 включительно мерила через mb_strwidth(), который считает таб за одну клетку. Строка a\tb\tc\td — четыре буквы и три таба — по этой мерке ровно семь клеток. Ровно ширина моей колонки. Значит, резать нечего, и ячейка возвращалась целиком. Рисовалась она на тринадцати.

Дальше работал уже мой код. Ячейка добивается пробелами до ширины колонки:

$padding = max(0, $width - AnsiUtils::visibleWidth($text));

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

Сто двадцать один тест при полном покрытии. Ни одного с табом.

Починили это в ядре и без меня — я узнал о баге, сравнивая две версии, когда он уже был исправлен. У себя чинить оказалось нечего, поэтому свою часть я закрыл иначе: написал тест, который падает на 8.1.4 и проходит на 8.1.5, и поднял нижнюю границу зависимости. Тест тут важнее фикса — он следит, чтобы пакет больше не ставился туда, где эта арифметика ещё сломана.

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

Что я сказал бы себе две недели назад

Полное покрытие ваших предположений — это не покрытие. 92 теста были не плохими. Они были написаны тем же человеком, в тот же день, с той же моделью в голове, что и код. Они не могли поймать баг с высотой: баг жил в модели.

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

Читайте диффы того, от чего зависите. Обновление патч-версии, где «ничего не сломалось», объяснило мне баг, который жил в моём пакете с первого дня.

Разработка поверх экспериментального компонента — честная сделка. Классы помечены как экспериментальные, что в терминах Symfony означает возможность сломать API в минорном релизе без депрекейшена. Взамен поле пустое, а мейнтейнеры внимательны: оба моих багфикса ушли в мерж в течение суток с открытия. Пиньте минорную версию по-настоящему — ~8.1.0, а не ^8.1, эти две записи означают разное, — следите за меткой компонента в трекере и читайте исходники того, на чём строите. Их всё равно придётся читать, документации пока нет.

Чтение исходников окупается дважды. Первый раз — в своём коде, второй — в багах, которые можно вернуть обратно.


Пакет называется tui-datatable, если захочется посмотреть, но суть была не в нём.

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

Пока текст лежал в черновиках, ответ пришёл сам. Сейчас зависимых пакетов девять. Среди них бандл с собственным DataTableWidget — постраничная навигация, полнотекстовый поиск через SQLite FTS5 и поддержка мыши, которую автор сделал сам, не дожидаясь, пока события мыши приедут в ядро. Установок у него больше, чем у меня.

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

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