Шесть дефектов, которых не увидели зелёные тесты: как я делал виджет для Claude Code

Шесть дефектов, которых не увидели зелёные тесты: как я делал виджет для Claude Code

Я сделал виджет на рабочий стол macOS, который показывает, сколько израсходовано от подписки Claude Code: пятичасовое окно, недельная квота, заполнение контекста и оценка того, когда неделя кончится. Ставится одной командой, подписан Developer ID и нотаризован Apple.

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

Что делает виджет

Три шкалы, все в одну сторону: сколько израсходовано. Больше — хуже. Полоска всегда совпадает с числом рядом.

Данные берутся из статуслайна Claude Code — той строки, которую CLI рисует под приглашением. Экспортёр на Python подставляется в statusLine, при каждой перерисовке пишет снимок в контейнер расширения, виджет его читает. Никакой сети: приложение не ходит никуда вообще.

Отдельно — оценка исчерпания недельной квоты. Она построена на взвешенной регрессии по вашей же истории и показывает дату только тогда, когда прямая действительно описывает точки. Иначе честно пишет, что данных мало. Это принципиальная позиция, а не осторожность: цифра, которая появляется, ничего не значая, хуже отсутствия цифры. Человек по ней планирует.

Дефект первый: последняя запись сессии выбрасывалась

WidgetKit жёстко ограничивает частоту перерисовки виджета. Поэтому наблюдатель рационировал перезагрузки: не чаще одной в минуту. Изменение, попавшее внутрь минуты, выбрасывалось — с обоснованием прямо в коде: «следующая запись экспортёра нас разбудит».

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

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

Дефект второй: окно говорило одно и то же дважды

Владелец прислал скриншот. Внизу панели «Подробности» одна и та же фраза напечатана два раза: под переключателем, где ей место, и в строке уведомления. Починить — минута. Интересно другое: как такое ловить вообще?

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

Замер: как было выпущено — 18 полос, один повтор. После починки — 17 полос, повторов нет. Все шесть состояний окна — повторов нет.

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

Дефект третий: слово, которое прочитали не о том

Пятичасовое окно сбрасывается — и до прихода новых данных строка показывала прочерк и слово «закрылось». Владелец в этот момент закрыл одно из двух окон IDE, увидел «Использовано за 5 часов — закрылось» и спросил, связано ли одно с другим.

Не связано: окно лимита сбросилось само, по расписанию, метки времени это подтвердили однозначно. Но прочтение закономерно: на экране «окно» — это окно программы, и «закрылось» относят к нему.

Слово заменено на «сброшено» — так не скажешь про окно программы, и это словарь остального интерфейса, где неделя «сбрасывается» в четверг. Замерено, что помещается в слот: 52 пункта из 66 доступных.

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

Дефект четвёртый: свежий снимок с несвежими числами

Разбирая предыдущую историю, я решил проверить встречную гипотезу: может ли одна сессия Claude Code положить в снимок устаревшие лимиты? Лимиты приходят с ответом модели и лежат в сессии до следующего ответа. Значит, сессия пишет то чтение, которое видела сама.

Проверил по истории машины — 522 записи, семь недельных окон. Тринадцать шагов назад внутри одного окна. Крупнейший: 27 % в 16:02 и 24 % в 16:09. Расход до сброса падать не может.

Первый прогон замера, кстати, отчитался о нуле таких случаев — потому что скрипт искал поле week, а оно называется sevenDay. Все 522 строки прочитались как «значения нет», ответ вышел чистым. Одинаковый результат на заведомо разных входах — признак, что измеряется не то.

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

Дефект пятый: фоновый режим сжёг дневной бюджет за час

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

Смотрю документацию Apple: дневной бюджет виджета — 40–70 обновлений в сутки, и не считаются те, что сделаны, «пока приложение-владелец на переднем плане». Шестьдесят в час против семидесяти в сутки. Дневная норма кончается до обеда, а дальше плитка замирает — и система об этом не сообщает никак: просто перестаёт вызывать провайдера.

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

Теперь два режима: на переднем плане — раз в минуту, бесплатно; позади — раз в 15 минут и только при сдвиге самих чисел. И строка в интерфейсе, которая обещала «в пределах минуты», исправлена вместе с поведением. Обещание, которое код перестал выполнять, — такой же дефект, как неверный расчёт.

Дефект шестой: пост о запуске

Я выложил анонс в X с двумя картинками — средней плиткой и большой. X ставит пару рядом и центрально обрезает под высокий формат. У широкой картинки срезало левый край: вместо «5-hour used» осталось «used».

Владелец сформулировал причину лучше меня: надо было сначала изучить, как платформа показывает изображения, а потом публиковать. Правило проекта требует источник или явную пометку «непроверено» перед любым утверждением о поведении платформы. «Приложить два снимка, которые есть» не было ни тем, ни другим — это была аналогия с тем, как картинки выглядят в README.

Заменено на одну картинку 16:9 со всеми тремя размерами. Собирается той же командой, что и остальные снимки, — значит, не может разойтись с тем, что рисуют виды.

Правила, которые из этого выросли

Все они лежат в репозитории, в CLAUDE.md. Вот те, что окупились быстрее всего.

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

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

Обоснование — тоже утверждение. Комментарий «так сделано, потому что X» описывает поведение системы и требует проверки наравне с самим поведением. Верная реализация с ложным объяснением опаснее отсутствия объяснения: следующий читатель примет решение по нему. Пятый дефект — ровно этот случай.

Тишина запрещена. Мягкий разбор, запасной путь, откат на другую стратегию — всё сопровождается записью в журнал. Три раза за проект тихий обход прятал настоящую проблему, и каждый выглядел как «данных нет».

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

## Цифры

Боевой код — 6443 строки Swift. Проверки — 8326 строк, 307 проверок в 32 наборах. Покрытие — 84,4 %. Источников в хранилище — 65, у каждого записана дата последнего чтения. Языков интерфейса — шесть. Коммитов — 138, с 29 июля.

Проверок по объёму больше, чем кода. Это не аккуратность ради аккуратности: почти каждая написана после конкретного дефекта и утверждает ровно то свойство, которого не хватило.

Что честно сказать про ограничения

Работает только с терминальным Claude Code. Виджет питается статуслайном, а он существует только в CLI. Через десктопное приложение или веб виджет останется пустым навсегда. Это стоит в README до инструкции по установке: читатель должен узнать, что инструмент ему не подойдёт, раньше, чем начнёт его ставить.

На macOS 14 и 15 не запускался ни разу. Минимальная версия заявлена, CI собирает на двух образах и проверяет minos в готовом двоичном файле, но сам виджет проверен вручную только на macOS 26. Поставить виджет на рабочий стол в CI нельзя — только перетаскиванием из галереи.

Одна находка открыта. Один раз средняя плитка нарисовалась чёрной. Четыре гипотезы опровергнуты замерами, воспроизвести не удалось. Записано как открытое, а не закрыто задним числом.

Исходники под лицензией MIT: github.com/davidkremlev/ccwidget

1