# Languagi: два сбоя в тестовой и превью-инфраструктуре

Языковой тренажёр для iOS (Swift 6, SwiftUI, 20 файлов исходников, 2217 строк, 19 unit-тестов + 1 сквозной UI-тест). Ниже — не история о том, как собирали приложение, а два конкретных технических инцидента, обнаруженных при попытке объективно подтвердить, что оно работает: автоматический тест падал без видимой причины в коде, а собранная HTML-страница не рендерилась в браузерном превью. Оба случая описаны так, как они были найдены: симптом, причина на уровне механизма (не косметика), исправление в коде/конфигурации, и измеримая проверка результата.

Живая версия этого README: см. `index.html` в этом репозитории (GitHub Pages).

---

## Кейс 1. XCUITest падал без видимой причины в коде

### Проблема

Сквозной UI-тест (`SmokeFlowTests.testFullEightScreenFlow`), проходящий весь пользовательский сценарий приложения от онбординга до paywall, падал на первом же запуске:

```
/LanguagiUITests/SmokeFlowTests.swift:18: error:
-[LanguagiUITests.SmokeFlowTests testFullEightScreenFlow] : XCTAssertTrue failed
Test Case '-[LanguagiUITests.SmokeFlowTests testFullEightScreenFlow]' failed (16.445 seconds).
```

Строка 18 — проверка, что кнопка «Продолжить» на экране онбординга неактивна до выбора цели. В коде приложения и теста на тот момент не было изменений, способных объяснить падение: онбординг был написан и провизуален заранее, юнит-тесты (19 штук) проходили стабильно.

### Причина

Экспорт accessibility-дерева в момент падения (`xcrun xcresulttool export attachments`) показал, что тест стартовал не с экрана онбординга, а сразу с уже пройденной тропы обучения — с существующим прогрессом (стрик 6, XP 45, узел «Заказ еды» помечен как пройденный). Тест был запущен с launch-аргументом `--uitest-reset`, который должен был гарантировать чистый старт, но прогресс сохранялся.

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

```
xcrun simctl spawn "iPhone 17 Pro" defaults write com.languagi.app languagi.progress.v1 -data <hex>
```

пишет в **device-уровневый** домен `defaults`, который физически отделён от **container-уровневого** `UserDefaults`, который читает и очищает сам код приложения (`UserDefaults.standard.removeObject(forKey:)` в `LanguagiApp.init()`). Приложение не может увидеть или удалить то, что было записано в обход его собственного контейнера — поэтому «сброс» в коде срабатывал, но старые данные оставались видны при следующем запуске через другой слой хранилища.

Отдельно, во втором прогоне после первого исправления, тест снова падал — на этот раз в шаге сборки фразы: тест выбирал слово `a` (оно дважды встречается в банке слов упражнения) первым найденным активным элементом, и иногда попадал на уже использованный дубликат из-за порядка элементов в accessibility-дереве (плитки в зоне ответа идут в дереве раньше плиток в банке слов).

### Решение

Оба исправления сделаны в коде, не через интерфейс симулятора:

1. Удалён весь device-уровневый домен явно, а не «сброшен» частично:
   ```
   xcrun simctl spawn "iPhone 17 Pro" defaults delete com.languagi.app
   ```
2. Логика выбора слова в тесте переписана так, чтобы детерминированно брать элемент **с конца** списка совпадений, а не первый попавшийся — поскольку плитки банка слов в accessibility-дереве идут после уже размещённых плиток ответа:
   ```swift
   for i in stride(from: chips.count - 1, through: 0, by: -1)
   where chips.element(boundBy: i).isEnabled { ... }
   ```

### Проверка

Не «прогнал и вроде прошло» — полный прогон `xcodebuild test` с явным выводом по каждому тест-кейсу, дважды подряд на чистом симуляторе (переустановка `.app` между прогонами):

```
Test Suite 'All tests' passed at 2026-07-23 12:22:59.578.
Test Case '-[LanguagiUITests.SmokeFlowTests testFullEightScreenFlow]' passed (31.751 seconds).
** TEST SUCCEEDED **
```

19 unit-тестов + 1 сквозной UI-тест — 20 из 20, включая полный проход всех восьми экранов на реальном симуляторе, воспроизводимо.

---

## Кейс 2. Собранная HTML-страница не рендерилась в браузерном превью

### Проблема

Локальная страница (self-hosted шрифты, изображения по относительным путям) при открытии через `file://` в браузерном превью отображалась без единого стиля: системный шрифт, никаких цветов, вместо картинок — alt-текст.

### Причина

Две независимые причины, обе подтверждены измерением, а не визуально:

**Причина A (разметка).** Проверка в консоли показала, что `document.fonts` пуст (ноль зарегистрированных `@font-face`), а `getComputedStyle(hero).backgroundColor` возвращает `rgba(0,0,0,0)` — то есть не «шрифт не подгрузился», а вся таблица стилей не распарсилась целиком. В исходнике был дублирующийся открывающий тег `<style>` и внутри CSS — случайно оставшийся закрывающий тег HTML-комментария `-->`. По правилам разбора HTML это означает, что браузер трактует всё содержимое от первого `<style>` до первого встреченного `</style>` как один текстовый блок CSS; второй `<style>` не открывает новый элемент, а невалидный токен `-->` внутри CSS ломает разбор всего листа целиком.

**Причина B (среда просмотра).** После исправления разметки страница всё ещё не рендерилась с картинками. `tabs_context` показал, что происхождение (origin) вкладки — `data:`, а не `file:`: инструмент предпросмотра конвертирует локальный файл в data-URL снимок без разрешаемого базового пути, поэтому все относительные ссылки (`src="fonts/…"`, `src="../appstore/…"`) не резолвятся.

### Решение

**Для A** — исправление в разметке: удалён дублирующийся `<style>` и невалидный `-->`, оставлен один корректный блок CSS-комментария.

**Для B** — поднят настоящий локальный HTTP-сервер вместо файлового превью, чтобы у страницы был реальный origin с разрешаемыми путями. Первая попытка (`python3 -m http.server`) падала при старте:

```
File ".../http/server.py", line 1274, in <module>
    parser.add_argument('--directory', '-d', default=os.getcwd(),
PermissionError: [Errno 1] Operation not permitted
```

Ошибка происходит на вызове `os.getcwd()` — то есть песочница процесса запрещает даже узнать свою текущую директорию, не говоря об аргументах. Собственный сервер, вообще не вызывающий `os.getcwd()`, падал так же — уже на попытке открыть собственный `.py`-файл, лежащий в `~/Desktop/…`. Перенос того же скрипта в каталог вне `~/Desktop` устранил ошибку немедленно — причина оказалась в ограничении доступа к папке Desktop для этого конкретного сборщика процесса на macOS, а не в коде сервера.

### Проверка

После обоих исправлений — не визуальная оценка, а измерения в самой странице:

```js
document.fonts.forEach(f => ...) // все 7 @font-face: "loaded"
document.documentElement.scrollWidth - window.innerWidth // 0 при 1440px и при 375px
```

и расчёт контраста по формуле WCAG (`(L1+0.05)/(L2+0.05)` из относительной яркости) для 7 пар текст/фон — все ≥ 5.5:1, часть выше 15:1.

---

## Что здесь не является инженерным кейсом

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

- Выбор цветовых токенов, типографики и структуры лендинг-страницы — дизайн-решения, не инженерная задача.
- Один найденный дефект (шрифты Space Grotesk / Plus Jakarta Sans, отмеченные линтером `detect.mjs` как «часто используемые ИИ-дефолты») был исправлен заменой на другую пару шрифтов и повторным прогоном того же линтера до нулевого результата — это эвристическая проверка стиля, не функциональный тест.
- Флаттенинг альфа-канала иконки приложения сделан отдельным Python/CoreGraphics-скриптом (код, не руками в редакторе изображений) — но проверка результата (`sips -g hasAlpha` → `no`) это единичная проверка одного файла, не автоматизированный шаг сборки.

## Что усилило бы кейс в следующий раз

- Флаттенинг иконки и проверка `hasAlpha` — вынести в build-фазу Xcode с падением сборки при прозрачном альфа-канале, вместо разового ручного запуска.
- Подбор символов для self-hosted шрифтов — сейчас подобран вручную «с запасом»; вместо этого можно сканировать реальный текст страницы на этапе сборки и явно падать, если встретился символ вне подготовленного набора, а не полагаться на то, что запас не кончится.
- Визуальную проверку (контраст, переполнение) можно перевести из разовых JS-снипетов в CI-тест с сохранённым baseline-скриншотом и пиксельным диффом — тогда регресс вёрстки будет ловить пайплайн, а не человек, листающий страницу.

## Стек

Swift 6, SwiftUI, XCTest/XCUITest, `xcodebuild`, `xcrun simctl`, `xcresulttool` — для кейса 1.
Vanilla HTML/CSS/JS, Python 3 (кастомный HTTP-сервер, CoreGraphics-скрипт для флаттенинга PNG), Google Fonts CSS2 API — для кейса 2.
`git`, GitHub CLI (`gh`) — версионирование и публикация этого репозитория.
