diff --git a/AI_MODEL_ROUTING_EXECPLAN.md b/AI_MODEL_ROUTING_EXECPLAN.md new file mode 100644 index 0000000..54b34a2 --- /dev/null +++ b/AI_MODEL_ROUTING_EXECPLAN.md @@ -0,0 +1,605 @@ +# Стабилизировать выбор AI-модели без изменения остальных подсистем + +Этот ExecPlan является живым документом. Разделы `Progress`, `Surprises & Discoveries`, `Decision Log` и `Outcomes & Retrospective` должны обновляться по мере выполнения работы. + +План ведётся в соответствии с `PLANS.md` в корне репозитория. Он намеренно ограничен единственной задачей: изменить только внутренний порядок выбора логической модели и API-подключения в Text Processing. Публичный и пользовательский интерфейсы, настройки, список поддерживаемых моделей, классификация бесплатности, сетевой протокол, семантика streaming, тексты ошибок и обработка результата в рамках этого плана не меняются. Единственное новое внутреннее API — scoped-метод gateway, который нельзя вызвать случайно из общего пути. + +## Purpose / Big Picture + +После выполнения плана существующая утилита «Обработка текста» выглядит и работает для пользователя так же, как сейчас, за одним исключением: режим `Автоматически` выбирает модель и API-ключ по детерминированному, объяснимому порядку. + +Каждая модель продолжает отображаться в ComboBox один раз независимо от количества ключей. Явно выбранная модель не подменяется другой. Если первый ключ выбранной модели получает ограничение, шлюз пробует следующий ключ этой же модели. В автоматическом режиме шлюз сначала перебирает все ключи одной логической модели и только после этого переходит к следующей логической модели. + +Изменение считается успешным, если перестановка моделей в ответе провайдера, переименование API-подключения и повторное открытие окна не приводят к случайному выбору другой модели, а все существующие функции и тесты остаются без регрессий. + +## Scope Lock + +Разрешены изменения только в алгоритме упорядочивания уже полученных и уже отфильтрованных моделей и подключений. + +Допустимый набор производственных файлов: + + AiteBar/AiGateway.cs + AiteBar/AiModelSelectionPolicy.cs + AiteBar/TextProcessingWindow.xaml.cs + +`AiModelSelectionPolicy.cs` является необязательным. Его следует добавить только если выделение чистой политики действительно упрощает тестирование. Если изменение остаётся компактным, политика может быть внутренним чистым методом `AiGateway`. + +В `TextProcessingWindow.xaml.cs` разрешено изменить только один call site запуска AI: заменить вызов общего `GenerateStreamingAsync` на специально выделенный внутренний метод Text Processing. Любое другое изменение этого файла запрещено. До и после реализации необходимо проверить точный diff этого файла. + +Допустимый набор тестовых файлов: + + AiteBar.Tests/AiProviderTests.cs + +Допустимо изменить этот ExecPlan по мере выполнения: + + AI_MODEL_ROUTING_EXECPLAN.md + +Любой другой файл считается вне области задачи. Если реализация требует изменить иной файл, выполнение необходимо остановить, записать причину в `Surprises & Discoveries` и пересмотреть план до внесения такого изменения. + +В частности, план запрещает: + +- менять `TextProcessingWindow.xaml`; +- менять в `TextProcessingWindow.xaml.cs` что-либо, кроме одного вызова gateway, выбирающего scoped-политику Text Processing; +- менять вид, размеры, подписи, список или порядок строк ComboBox; +- менять `Models.cs`, формат `settings.json` или добавлять новые настройки; +- менять `AiModels.cs` и публичные контракты `AiChatRequest`, `AiGatewayResponse`, `AiGatewayStream`; +- менять `AiProviderClient.cs`, HTTP-запросы, тайм-ауты и streaming; +- менять `AiProviderCatalog.cs`, список провайдеров или определение бесплатности; +- менять фильтрацию платных, неизвестных, нетекстовых, image/video и deprecated-моделей; +- менять вычисление контекста или оценку токенов; +- менять тексты ошибок, ресурсы локализации и документацию пользователя; +- менять время жизни `AiGateway`, устройство кэша и сохранение runtime-состояния; +- добавлять сохранение истории, текста, квот или здоровья ключей на диск; +- вводить round-robin, балансировку нагрузки или параллельные запросы; +- менять поведение начавшегося потока после получения первого чанка. + +Эти ограничения являются частью приёмки, а не рекомендациями. + +## Execution Safety Gate + +План ограничивает запись, а не необходимое read-only изучение зависимостей. Агент может читать связанные типы и тесты, чтобы не сломать существующие контракты, но не может изменять их. + +Перед первым патчем агент обязан: + +1. Сохранить `git status --short`. +2. Создать временный каталог вне репозитория и скопировать туда каждый уже существующий разрешённый файл, потому что рабочее дерево содержит пользовательские изменения. Копии являются точным baseline этой задачи и не добавляются в Git. +3. Вычислить SHA-256 всех защищённых исходных файлов, перечисленных в `Concrete Steps`. +4. Зафиксировать точные разрешённые hunks: scoped-метод gateway, чистая политика, одна строка call site и тесты. + +После каждого патча агент обязан выполнить `git diff --name-only` и проверить, что не появился новый исходный файл вне allowlist. Если появился, работа немедленно останавливается. Агент не продолжает реализацию и не «исправляет заодно» обнаруженный посторонний код. + +В конце SHA-256 защищённых файлов повторно вычисляется и сравнивается с исходными значениями. Разрешённые существующие файлы сравниваются со своими временными baseline-копиями через `git diff --no-index`. Для `TextProcessingWindow.xaml.cs` результирующий diff обязан содержать ровно одну замену имени вызываемого gateway-метода без форматирования соседнего кода. + +Запрещены sub-agent delegation, автоматическое форматирование всего проекта, обновление пакетов, сетевой поиск, изменение версии, commit, push, publish, installer и запуск приложения. Разрешены только локальная сборка и тесты. Генерируемые `bin`, `obj` и отдельный проверочный каталог внутри `artifacts` не считаются изменением исходного кода. + +Если реализация не помещается в allowlist или требует нового контракта, агент обязан остановиться и запросить отдельное разрешение пользователя. Самостоятельно расширять scope нельзя. + +## Progress + +- [x] (2026-07-25) Повторно проверен первоначальный план и обнаружено, что он выходил за разрешённую область задачи. +- [x] (2026-07-25) Зафиксирован строгий scope lock: только порядок выбора уже доступных моделей и ключей. +- [x] (2026-07-25) Зафиксированы существующие инварианты, которые нельзя менять. +- [x] (2026-07-25) Определён детерминированный порядок моделей и маршрутов на основе уже существующих настроек. +- [x] (2026-07-25) Устранены неоднозначности ревизии: выбран единый трёхполевой `AiRouteCandidate`, проверен фактический контракт automatic/exact запросов, добавлены жёсткие baseline-gates и построчная ревизия `BuildRoutesAsync`. +- [ ] Перед реализацией добавить характеристические тесты текущего разрешённого поведения. +- [ ] Реализовать чистую детерминированную политику упорядочивания маршрутов. +- [ ] Подключить политику внутри `AiGateway.BuildRoutesAsync` без изменения остальных стадий. +- [ ] Добавить регрессионные тесты точного и автоматического выбора. +- [ ] Проверить allowlist изменённых файлов, Release-сборку и полный набор тестов. +- [ ] Записать фактические результаты в `Outcomes & Retrospective`. + +## Surprises & Discoveries + +- Observation: Пользовательская дедупликация моделей уже реализована и не требует дальнейшего изменения. + Evidence: `TextProcessingWindow.BuildLogicalModelItems` объединяет записи по `ProviderId + ModelId`, а ComboBox получает готовые логические элементы. + +- Observation: Базовая ротация ключей одной модели уже работает. + Evidence: `AiGateway.BuildRoutesAsync` группирует маршруты по `ProviderId + ModelId`, после чего `GenerateAsync` и `GenerateStreamingAsync` обходят маршруты в полученном порядке. + +- Observation: Явный выбор уже передаёт только `PreferredProviderId + PreferredModelId` и не закрепляет Text Processing за одним API-подключением. + Evidence: `TextProcessingWindow.CopyRequestWithModel` устанавливает `RequireExactModel=true` без `PreferredConnectionId`. + +- Observation: Недетерминированность сосредоточена в порядке маршрутов, а не в фильтрации. + Evidence: порядок логических групп определяется первым появлением модели при обходе подключений и каталогов. Каталог внешнего провайдера может вернуть те же модели в другом порядке. + +- Observation: Отображаемое имя подключения участвует в маршрутизации. + Evidence: `AiGateway.BuildCandidates` применяет `ThenBy(connection => connection.DisplayName)`. Переименование подключения может изменить первый используемый ключ. + +- Observation: В настройках уже существует `AiConnectionSettings.PreferredModelId`. + Evidence: текущий `GetEligibleModels` поднимает эту модель в начало каталога конкретного подключения. Новая политика должна сохранить смысл этого поля без добавления нового формата настроек. + +- Observation: Список `AiSettings.Connections` уже имеет устойчивый сериализованный порядок. + Evidence: настройки хранят подключения как `List`. Этот порядок можно использовать как единственный стабильный tie-break ключей, не меняя модель данных. + +- Observation: `AiGateway` является общим типом приложения, а не приватной деталью Text Processing. + Evidence: `MainWindow` создаёт собственный `AiGateway` и возвращает его через `GetAiGateway()`. Сейчас фактический вызов генерации найден только в `TextProcessingWindow`, но изменение общего метода создало бы скрытый риск для будущих или внешних потребителей. + +## Decision Log + +- Decision: Не исправлять в этом плане бесплатность, кэширование каталогов, классификацию ошибок и lifetime шлюза. + Rationale: Это отдельные архитектурные задачи. Пользователь разрешил изменить только подход к выбору модели; совместное изменение нескольких подсистем повышает риск регрессии и усложняет проверку. + Date/Author: 2026-07-25 / Codex + +- Decision: Не менять существующую идентичность логической модели `ProviderId + ModelId`. + Rationale: Она уже используется UI и gateway, корректно скрывает повторение ключей и не смешивает модели разных провайдеров. + Date/Author: 2026-07-25 / Codex + +- Decision: Не менять фильтры кандидатов. + Rationale: `deprecated`, capabilities, writing suitability, free-only и context capacity уже применяются до упорядочивания. Новая политика получает только прошедшие текущие фильтры маршруты. + Date/Author: 2026-07-25 / Codex + +- Decision: Для явного режима сохранить строгий `ProviderId + ModelId`. + Rationale: Явный выбор пользователя никогда не должен переходить к другой модели или провайдеру. Меняется только ключ внутри этой же пары. + Date/Author: 2026-07-25 / Codex + +- Decision: Для автоматического режима использовать существующие предпочтения и стабильные технические идентификаторы. + Rationale: Добавление нового рейтинга моделей, новых настроек или внешней базы качества выходит за scope. Детерминированность можно получить из уже существующих `ProviderOrder`, `Connections` и `PreferredModelId`. + Date/Author: 2026-07-25 / Codex + +- Decision: Стабильный порядок ключей определяется позицией подключения в `AiSettings.Connections`. + Rationale: Порядок списка настроек не зависит от локализации и переименования. `DisplayName` остаётся только пользовательской подписью и не участвует в маршрутизации. + Date/Author: 2026-07-25 / Codex + +- Decision: Использовать primary-first failover, а не round-robin. + Rationale: Текущее ожидаемое поведение — использовать очередной ключ до ограничения, затем следующий. Балансировка нагрузки является другой продуктовой политикой и не вводится скрыто. + Date/Author: 2026-07-25 / Codex + +- Decision: Не продолжать начавшийся streaming другим ключом. + Rationale: После выдачи части текста повторный запрос может создать другой результат. Текущее поведение streaming не относится к порядку первоначального выбора и остаётся неизменным. + Date/Author: 2026-07-25 / Codex + +- Decision: Не менять поведение существующих общих `AiGateway.GenerateAsync` и `AiGateway.GenerateStreamingAsync`. + Rationale: `AiGateway` доступен через `MainWindow` и потенциально является общей инфраструктурой. Новая детерминированная политика должна включаться только отдельным внутренним методом, вызываемым Text Processing. + Date/Author: 2026-07-25 / Codex + +- Decision: Добавить scoped-метод `GenerateTextProcessingStreamingAsync`, который делегирует тому же streaming-core, но передаёт детерминированную политику маршрутов. + Rationale: Это создаёт явную границу безопасности без нового поля настроек, изменения `AiChatRequest` или влияния на другие callers. В окне меняется только имя вызываемого метода. + Date/Author: 2026-07-25 / Codex + +## Existing Invariants + +Следующие условия должны быть подтверждены тестами до изменения кода и остаться истинными после него. + +Одна логическая модель соответствует одной паре `ProviderId + ModelId`. Количество API-ключей не создаёт дополнительные пользовательские модели. + +Явный запрос содержит `RequireExactModel=true`, `PreferredProviderId` и `PreferredModelId`. Если такая модель отсутствует на конкретном ключе, шлюз пропускает этот ключ. Если она отсутствует на всех ключах, другая модель не выбирается. + +Автоматический запрос использует текущие фильтры: + +- требуемые capabilities; +- `RequireWritingModel`; +- `FreeTierOnly` или `RequireFreeModel`; +- отсутствие `IsDeprecated`; +- достаточный `RequiredContextTokens`. + +При `429` или другой уже обрабатываемой ошибке до начала streaming шлюз продолжает текущий список маршрутов. Существующие правила `ApplyFailure`, cooldown и status dictionaries не меняются. + +`PreferredConnectionId`, если его передал другой существующий caller, продолжает ограничивать кандидаты одним подключением. Text Processing его не использует, но обратная совместимость `AiChatRequest` сохраняется. + +`ProviderOrder` продолжает задавать приоритет провайдеров. + +`AiConnectionSettings.PreferredModelId` продолжает влиять только на автоматический выбор. При `RequireExactModel=true` оно игнорируется в пользу `request.PreferredModelId`. + +В текущем Text Processing автоматический запрос создаётся через `TextProcessingService.BuildRequest` и не содержит `PreferredProviderId` или `PreferredModelId`. Эти два поля устанавливаются только `TextProcessingWindow.CopyRequestWithModel`, одновременно с `RequireExactModel=true`. Scoped entry point принимает это как строгий контракт: automatic означает `RequireExactModel=false`, `PreferredProviderId=null` и `PreferredModelId=null`; exact означает `RequireExactModel=true` и оба непустых идентификатора. + +Обычная и потоковая генерация используют один и тот же упорядоченный список маршрутов до начала выполнения запроса. + +Существующие общие методы `GenerateAsync` и `GenerateStreamingAsync` сохраняют прежний порядок маршрутов. Новый порядок применяется только через opt-in метод Text Processing. Это намеренное уточнение границы: инвариант общего gateway важнее унификации всех его entry points. + +## Deterministic Selection Contract + +Новая политика применяется только scoped-путём Text Processing и должна принимать: + + AiSettings settings + AiChatRequest request + IReadOnlyList eligibleRoutes + +`eligibleRoutes` уже прошли существующую фильтрацию. Политика не имеет права добавлять, удалять, дедуплицировать или повторно классифицировать модель. Она только возвращает перестановку того же множества маршрутов. Общие entry points gateway продолжают использовать существующий порядок и не вызывают эту политику. + +Формальный инвариант политики: + + output.Count == input.Count + multiset(output route identities) == multiset(input route identities) + +Идентичность маршрута для этой проверки: + + Connection.Id + ProviderId + ModelId + +Если scoped-запрос нарушает контракт режима, scoped entry point должен завершиться fail-closed через `InvalidOperationException` до `BuildCandidates`, чтения каталогов и сетевых вызовов. Политика повторяет эту проверку защитно для прямых unit-тестов и дополнительно валидирует уже собранные exact-кандидаты. Нарушениями считаются: exact mode без одного из двух идентификаторов; automatic mode с непустым `PreferredProviderId` или `PreferredModelId`; exact-кандидат, не совпадающий с запрошенной provider/model. Нельзя удалять «неподходящие» маршруты или подставлять другую модель. Такое исключение означает дрейф контракта между Text Processing и gateway и должно быть покрыто отдельными unit-тестами. + +Маршрут-кандидат содержит ссылку на существующие объекты: + + internal sealed record AiRouteCandidate( + AiConnectionSettings Connection, + AiModelDescriptor Model, + int ConnectionOrder); + +Это единственное объявление и окончательный набор полей для плана. `ProviderRank` и `PreferredModelRank` вычисляются внутри `AiModelSelectionPolicy.OrderRoutes` из `AiSettings`, `AiChatRequest` и участвующих кандидатов; они не хранятся в `AiRouteCandidate`. + +### Порядок провайдеров + +В automatic mode `request.PreferredProviderId` по контракту Text Processing отсутствует и не участвует в ранжировании. Сначала используются значения `settings.ProviderOrder`. Затем добавляются отсутствующие значения `AiProviderCatalog.DefaultProviderOrder`. Повторения удаляются без учёта регистра. + +В exact mode провайдер уже однозначно задан `request.PreferredProviderId`; после существующей фильтрации политика видит только эту provider/model и сортирует ключи. Любое смешение полей automatic и exact считается нарушением scoped-контракта и завершается fail-closed. + +### Порядок предпочитаемых моделей + +Для каждого провайдера строится список предпочитаемых моделей из уже существующих `AiConnectionSettings.PreferredModelId` только тех подключений, которые включены, принадлежат известному провайдеру и уже вошли в набор кандидатов `BuildCandidates`. + +Подключения рассматриваются в порядке `settings.Connections`. Отключённое, неизвестное или исключённое через `PreferredConnectionId` подключение не влияет на rank. Пустые значения пропускаются. Повторяющиеся `ModelId` удаляются без учёта регистра. Поэтому если несколько участвующих ключей одного провайдера содержат разные предпочтения, первое предпочтение в сохранённом списке получает более высокий приоритет. + +Никакой новый preference не создаётся. Значения настроек не изменяются. + +### Порядок логических моделей в автоматическом режиме + +Логические модели сортируются по следующему кортежу: + + ProviderRank + PreferredModelRank + ModelId ordinal-ignore-case + +`PreferredModelRank` равен позиции модели в списке предпочтений провайдера. Для модели без preference используется `int.MaxValue`. + +`ModelId` является последним стабильным tie-break. `DisplayName`, локализованная подпись и порядок модели в JSON не используются. + +Это не пытается определить «лучшую нейросеть» по скрытой эвристике. Политика лишь делает существующие настройки детерминированными. Если в будущем понадобится продуктовый рейтинг качества моделей, он должен быть отдельным явно разрешённым изменением. + +### Порядок логических моделей в явном режиме + +При `RequireExactModel=true` существующий `GetEligibleModels` до вызова политики обязан оставить только совпадение: + + connection.ProviderId == request.PreferredProviderId + model.ModelId == request.PreferredModelId + +Сравнение выполняется без учёта регистра. Если `PreferredProviderId` или `PreferredModelId` отсутствует, scoped-контракт нарушен и до выполнения маршрута выбрасывается `InvalidOperationException`; молчаливый fallback запрещён. Никакие preference подключения и fallback-модели не применяются. + +Политика повторно не фильтрует exact mode. Она валидирует, что каждый полученный маршрут уже соответствует provider/model запроса, и затем сортирует только ключи. При нарушении входного контракта применяется fail-closed поведение, описанное выше. + +### Порядок ключей внутри модели + +Маршруты одной логической модели сортируются по позиции `Connection` в `settings.Connections`. + +Если `PreferredConnectionId` задан, существующий `BuildCandidates` сначала ограничивает набор одним подключением; политика не расширяет этот набор. + +`DisplayName`, `CredentialTarget`, состояние локализации и порядок ответа каталога не участвуют в сортировке ключей. + +### Итоговый порядок + +После сортировки результат разворачивается строго как: + + Logical model 1 + Connection 1 + Connection 2 + Connection N + Logical model 2 + Connection 1 + Connection 2 + Connection N + +Таким образом, режим `Автоматически` исчерпывает все ключи текущей модели до следующей модели. Явный режим содержит ровно одну логическую модель. + +## Plan of Work + +### Milestone 1: Зафиксировать текущее поведение тестами + +До любого изменения production-кода сначала проверить точное наличие трёх заявленных характеристических тестов: + + rg -n "Gateway_ExactModel_TriesNextConnectionAfterRateLimit|Gateway_ExactSelection_UsesAllRequestedProviderConnectionsAndNeverChangesModel|Gateway_AutomaticMode_ExhaustsSameModelRoutesBeforeChangingModel" .\AiteBar.Tests\AiProviderTests.cs + +На ревизии 2026-07-25 они найдены соответственно на строках 50, 176 и 93. Исполнитель обязан повторить проверку в своей фактической исходной версии. Если хотя бы один тест отсутствует, переименован или находится не в ожидаемом тестовом проекте, это записывается в `Surprises & Discoveries`, реализация останавливается до обновления плана; нельзя молча объявить отсутствующий тест «существующим» или заменить его новым. + +Затем, всё ещё до изменения production-кода, в `AiteBar.Tests/AiProviderTests.cs` добавить или уточнить остальные характеристические тесты, которые доказывают разрешённые инварианты. + +Нужны следующие тесты: + +1. `Gateway_ExactModel_TriesNextConnectionAfterRateLimit` — уже существует и должен остаться зелёным. +2. `Gateway_ExactSelection_UsesAllRequestedProviderConnectionsAndNeverChangesModel` — уже существует и должен остаться зелёным. +3. `Gateway_AutomaticMode_ExhaustsSameModelRoutesBeforeChangingModel` — уже существует и должен остаться зелёным. +4. Новый тест: платная модель не становится кандидатом после введения политики. +5. Новый тест: модель с недостаточным контекстом не становится кандидатом. +6. Новый тест: `PreferredConnectionId` по-прежнему ограничивает маршруты одним ключом. +7. Новый тест: ошибка streaming после начала выдачи не запускает второй запрос. +8. Новый тест: scoped automatic request с `PreferredProviderId` или `PreferredModelId` завершается `InvalidOperationException`. +9. Новый тест: scoped exact request без любого из двух идентификаторов завершается `InvalidOperationException`. + +На этом milestone производственный код не меняется. Если какой-либо тест выявляет отличающееся текущее поведение, результат фиксируется в `Surprises & Discoveries`; тест нельзя подгонять под желаемый результат без отдельного решения. + +### Milestone 2: Реализовать чистую сортировку + +Предпочтительный вариант — добавить `AiteBar/AiModelSelectionPolicy.cs`. Он содержит единственное объявление `AiRouteCandidate`, уже полностью зафиксированное в разделе `Deterministic Selection Contract`, и чистую политику: + + internal static class AiModelSelectionPolicy + { + public static IReadOnlyList OrderRoutes( + AiSettings settings, + AiChatRequest request, + IEnumerable routes); + } + +Метод должен быть чистым: + +- не выполнять сетевых запросов; +- не читать настройки через `AppSettingsService`; +- не читать время; +- не менять входные объекты; +- не обращаться к WPF; +- не фильтровать бесплатность, capabilities или context; +- не обновлять здоровье подключений; +- возвращать новый массив в детерминированном порядке. + +Если добавление отдельного файла потребует изменения project-файла, сначала проверить SDK-style glob. В текущем .NET SDK проекте `.cs` обычно включаются автоматически; `AiteBar.csproj` менять нельзя. + +В `AiteBar.Tests/AiProviderTests.cs` добавить чистые тесты политики: + +- выход содержит в точности тот же multiset идентичностей маршрутов, что и вход; +- политика не теряет маршруты, не создаёт новые и не удаляет существующие дубли входа; +- одинаковый набор маршрутов в разном входном порядке даёт одинаковый выход; +- изменение `DisplayName` не меняет выход; +- перестановка JSON-моделей не меняет выход; +- `ProviderOrder` применяется первым; +- существующие `PreferredModelId` применяются в порядке `settings.Connections`; +- непреференциальные модели получают стабильный порядок по `ModelId`; +- ключи модели идут в порядке `settings.Connections`; +- exact mode содержит только точную provider/model; +- нарушение exact-контракта вызывает `InvalidOperationException`, а не скрытую фильтрацию или fallback; +- automatic mode принимает только пустые `PreferredProviderId` и `PreferredModelId`; непустое значение вызывает `InvalidOperationException`; +- разные провайдеры с одинаковым `ModelId` не объединяются. + +Milestone завершён, когда тесты чистой политики проходят и ни один существующий production path ещё не переключён. + +### Milestone 3: Подключить политику в AiGateway + +Общие `GenerateAsync`, `GenerateStreamingAsync` и `BuildCandidates` должны сохранить прежнее поведение. Для Text Processing добавить отдельный внутренний entry point: + + internal Task GenerateTextProcessingStreamingAsync( + AiChatRequest request, + CancellationToken cancellationToken = default); + +Оба streaming entry point должны делегировать одному private core, но передавать разный режим упорядочивания: + + GenerateStreamingAsync + -> Legacy ordering + + GenerateTextProcessingStreamingAsync + -> DeterministicTextProcessing ordering + +Оба streaming entry point используют существующий `BuildCandidates` без изменений. Создавать `BuildTextProcessingCandidates` запрещено: второй builder продублировал бы фильтры enabled/provider/exact и мог бы разойтись с общим путём. + +Хотя `BuildCandidates` возвращает подключения в legacy-порядке с `DisplayName`, scoped-политика не доверяет входному порядку. Она восстанавливает `ConnectionOrder` через индекс `Connection.Id` в исходном `settings.Connections`, а `ProviderRank` — через существующий `ProviderOrder`. Поэтому Text Processing получает стабильный порядок без изменения или дублирования `BuildCandidates`. + +`BuildRoutesAsync` может получить внутренний параметр режима сортировки. До патча исполнитель обязан найти и перечислить в `Progress` каждый call site командой `rg -n "BuildRoutesAsync\\(" .\AiteBar\AiGateway.cs`; на ревизии 2026-07-25 их два, из `GenerateAsync` и `GenerateStreamingAsync`. После патча поиск повторяется, каждый найденный вызов должен быть объяснён, а все legacy entry points обязаны передавать legacy-режим явно. + +В legacy-режиме метод обязан вернуть не просто эквивалентный набор, а точно прежнюю последовательность маршрутов: одинаковые `Count` и порядок идентичностей `Connection.Id + ProviderId + ModelId`, включая повторы. Существующая сборка `routeGroups` и разворачивание групп в этой ветке сохраняются без перестановки, новой сортировки или смены LINQ-операций. В Text Processing режиме после сбора уже отфильтрованных маршрутов он вызывает: + + AiModelSelectionPolicy.OrderRoutes(settings, request, collectedRoutes) + +Обе ветки `BuildRoutesAsync` должны сохранить без изменений: + +- последовательное получение кэшированных каталогов; +- `IsConnectionAvailable`; +- `GetEligibleModels`; +- обработку исключений получения каталога; +- существующий `lastError`; +- существующий cache и semaphore. + +Старую зависимость Text Processing от первого появления модели удалить только в scoped-ветке. Legacy-ветка сохраняет её для обратной совместимости. Набор кандидатов, фильтры и сетевые обращения обеих веток остаются одинаковыми; различается только финальная перестановка собранных маршрутов. + +В `TextProcessingWindow.xaml.cs` заменить только: + + _gateway.GenerateStreamingAsync(...) + +на: + + _gateway.GenerateTextProcessingStreamingAsync(...) + +Никакие другие строки окна не менять. + +`GenerateAsync`, публичный контракт `GenerateStreamingAsync`, `ObserveStreamAsync`, `ApplyFailure`, `MarkSuccessful`, `GetQuotaKey` и `AiProviderClient` семантически не менять. + +После интеграции повторить все тесты Milestone 1 и 2. + +Затем выполнить обязательную ручную ревизию `BuildRoutesAsync`: сравнить `AiGateway.cs` с сохранённой baseline-копией через `git diff --no-index`, просмотреть построчно каждый изменённый hunk метода и письменно отметить в `Progress`, почему каждая строка не меняет legacy-последовательность. При сомнении реализация останавливается; формулировки «семантически эквивалентно» недостаточно. + +### Milestone 4: Проверить отсутствие побочных изменений + +Проверить allowlist: + + git diff --name-only + +В результате этой задачи среди новых изменений допустимы только: + + AI_MODEL_ROUTING_EXECPLAN.md + AiteBar/AiGateway.cs + AiteBar/AiModelSelectionPolicy.cs + AiteBar/TextProcessingWindow.xaml.cs + AiteBar.Tests/AiProviderTests.cs + +В репозитории уже существует грязное рабочее дерево. Поэтому проверка должна сравнивать не весь `git status`, а конкретный diff этой задачи с сохранённым перед началом списком и содержимым. Нельзя изменять или откатывать посторонние пользовательские файлы. + +Проверить отдельно: + +- `TextProcessingWindow.xaml` не изменён этой задачей; +- diff `TextProcessingWindow.xaml.cs` содержит ровно замену одного имени метода gateway; +- `AiProviderClient.cs` не изменён этой задачей; +- `AiModels.cs` не изменён этой задачей; +- `AiProviderCatalog.cs` не изменён этой задачей; +- `Models.cs` и `AppSettingsService.cs` не изменены этой задачей; +- resource-файлы не изменены этой задачей; +- пользовательская документация не изменена этой задачей. + +Добавить отдельный регрессионный тест с намеренно переставленным каталогом и несколькими подключениями. Для общих `GenerateAsync` и `GenerateStreamingAsync` он должен утверждать точную предрефакторинговую последовательность попыток по идентичностям `Connection.Id + ProviderId + ModelId`: то же количество, тот же порядок, те же повторы. Для `GenerateTextProcessingStreamingAsync` тот же fixture должен утверждать новый детерминированный порядок. Так граница безопасности проверяется исполняемым кодом, а не только сравнением множеств или комментариями. + +Запустить focused-тесты и полный набор. После этого провести только read-only проверку окна: кроме имени вызываемого метода код не должен требовать изменения привязок, размеров, состояния или подписей. + +## Concrete Steps + +Рабочий каталог: + + D:\01_Codebdbd\01_projects\aitebar + +Перед началом реализации сохранить исходное состояние: + + git status --short + git diff -- AiteBar/AiGateway.cs AiteBar/TextProcessingWindow.xaml.cs AiteBar.Tests/AiProviderTests.cs + $routingBaseline = Join-Path $env:TEMP ("aitebar-model-routing-baseline-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $routingBaseline + Copy-Item -LiteralPath .\AiteBar\AiGateway.cs -Destination (Join-Path $routingBaseline "AiGateway.cs") + Copy-Item -LiteralPath .\AiteBar\TextProcessingWindow.xaml.cs -Destination (Join-Path $routingBaseline "TextProcessingWindow.xaml.cs") + Copy-Item -LiteralPath .\AiteBar.Tests\AiProviderTests.cs -Destination (Join-Path $routingBaseline "AiProviderTests.cs") + Write-Output $routingBaseline + Get-FileHash AiteBar/TextProcessingWindow.xaml + Get-FileHash AiteBar/AiProviderClient.cs + Get-FileHash AiteBar/AiModels.cs + Get-FileHash AiteBar/AiProviderCatalog.cs + Get-FileHash AiteBar/Models.cs + Get-FileHash AiteBar/AppSettingsService.cs + git diff --check + +До первого изменения любого production-файла выполнить свежий полный baseline-прогон: + + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release + +Историческое значение 897 не является критерием текущего количества. В `Progress` записать фактические `Passed`, `Failed`, `Skipped` и общее число тестов именно этого прогона. Если есть хотя бы одно падение, реализация останавливается и результат заносится в `Surprises & Discoveries`. Если падений нет, но количество отличается от 897, записать новое число и использовать его как baseline этой реализации; изменение числа само по себе не разрешает продолжить без проверки причин. + +Напечатанный путь `$routingBaseline`, исходные SHA-256, найденные call sites `BuildRoutesAsync`, подтверждение наличия трёх обязательных тестов и результат полного baseline-прогона записать в `Progress` этого ExecPlan до первого изменения production-кода. Если выполнение продолжается в новой PowerShell-сессии, восстановить `$routingBaseline` из записанного абсолютного пути; не создавать новый baseline после начала правок. + +После характеристических тестов: + + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release --filter "FullyQualifiedName~Gateway_" + +После чистой политики: + + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release --filter "FullyQualifiedName~AiModelSelectionPolicy" + +После интеграции: + + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release --filter "FullyQualifiedName~Gateway_|FullyQualifiedName~AiStreamingTests|FullyQualifiedName~TextProcessingModelEligibilityTests" + +Финальная Release-сборка: + + dotnet build .\AiteBar.sln -c Release -m:1 -nr:false + +Финальный полный набор: + + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release + +Если обычные `bin`/`obj` заблокированы WPF/MSBuild, использовать новый каталог внутри `artifacts`: + + dotnet build .\AiteBar.sln -c Release -m:1 -nr:false -p:ReleaseVerificationRoot=<новый путь внутри artifacts> + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release -p:ReleaseVerificationRoot=<новый путь внутри artifacts> + +При финальной проверке выполнить: + + git diff --check + git diff --name-only + git diff --no-index -- (Join-Path $routingBaseline "AiGateway.cs") .\AiteBar\AiGateway.cs + git diff --no-index -- (Join-Path $routingBaseline "TextProcessingWindow.xaml.cs") .\AiteBar\TextProcessingWindow.xaml.cs + git diff --no-index -- (Join-Path $routingBaseline "AiProviderTests.cs") .\AiteBar.Tests\AiProviderTests.cs + Get-FileHash AiteBar/TextProcessingWindow.xaml + Get-FileHash AiteBar/AiProviderClient.cs + Get-FileHash AiteBar/AiModels.cs + Get-FileHash AiteBar/AiProviderCatalog.cs + Get-FileHash AiteBar/Models.cs + Get-FileHash AiteBar/AppSettingsService.cs + +`git diff --no-index` возвращает exit code `1`, когда ожидаемые различия существуют; это не является ошибкой проверки. Необходимо вручную подтвердить, что diff содержит только разрешённые hunks. Итоговые SHA-256 защищённых файлов должны в точности совпасть со значениями, записанными до начала. + +Не запускать приложение автоматически, если уже существует запущенный экземпляр AiteBar. Данная задача не требует изменения UI и может быть полностью проверена fake-HTTP и pure unit-тестами. Ручной запуск допускается только по отдельной команде пользователя. + +## Validation and Acceptance + +План считается выполненным только при одновременном соблюдении всех условий. + +Видимый список моделей не изменился. Одна модель остаётся одной строкой независимо от количества ключей. + +Явно выбранная модель использует только точные `ProviderId + ModelId`. При недоступности первого ключа пробуется следующий ключ этой модели. Другая модель не вызывается. + +Автоматический режим использует: + + ProviderOrder + существующие PreferredModelId + стабильный ModelId + порядок Connections для ключей + +Порядок ответа каталога и `DisplayName` не влияют на выбор. + +Все ключи логической модели идут подряд до следующей модели. + +Это новое поведение включается только вызовом `GenerateTextProcessingStreamingAsync` из Text Processing. Общие `GenerateAsync` и `GenerateStreamingAsync` сохраняют прежний порядок и подтверждены отдельным сравнительным тестом. + +Новая политика возвращает точную перестановку входных маршрутов: количество и multiset `ConnectionId + ProviderId + ModelId` до и после совпадают. Политика не фильтрует кандидатов второй раз. + +Фильтрация моделей до сортировки побитово и логически не изменена. Те же модели считаются бесплатными, текстовыми, deprecated и подходящими по контексту, что и до задачи. + +Обработка `401`, `403`, `402`, `429`, `5xx`, network, timeout и cancellation не изменена. + +Публичная обычная и публичная потоковая генерация сохраняют прежний порядок маршрутов. Scoped-поток Text Processing использует новую политику только до начала запроса. Начавшийся streaming не переключается незаметно на другой ключ. + +Формат настроек и сохранённые значения не изменены. Новых полей JSON нет. + +XAML, размеры окна, локализация, команды, кнопки, ComboBox, статусная строка, обработка текста, diff, Undo/Redo и защита технических фрагментов не изменены. В code-behind окна допустима ровно одна замена вызова общего gateway на scoped-метод. + +Release-сборка завершается с нулём ошибок и предупреждений. Все существующие и новые тесты проходят. Значение 897 — только исторический снимок на момент одной из ревизий плана. При старте реализации обязателен новый полный baseline-прогон; итог сравнивается с зафиксированным фактическим результатом этого прогона и не должен иметь ни одного падения. + +## Idempotence and Recovery + +Чистая политика сортировки не хранит состояние, поэтому повторный вызов с тем же набором маршрутов всегда даёт тот же результат. + +Интеграция выполняется одним вызовом политики после существующей фильтрации. Если новый порядок вызывает регрессию, можно временно вернуть старое разворачивание `routeGroups`, не затрагивая UI, provider client, настройки или status dictionaries. + +Нельзя использовать `git reset --hard`, `git checkout --` или удалять существующие изменения грязного рабочего дерева. Откат допустим только точечным патчем файлов, изменённых этой задачей. + +Новый файл политики не содержит миграции или persistent state. Его удаление вместе с возвратом прежнего вызова полностью восстанавливает старый выбор. + +## Artifacts and Notes + +Исторический baseline на момент пересмотра плана (не заменяет обязательный свежий прогон перед реализацией): + + Release-сборка: 0 предупреждений, 0 ошибок. + Полный набор: 897 пройдено, 0 не пройдено. + UI уже дедуплицирует ProviderId + ModelId. + Exact mode уже ротирует ключи одной модели. + Automatic mode уже использует model-first маршрут, + но порядок логических моделей зависит от порядка каталогов. + +Ключевое отличие пересмотренного плана от первоначального: удалены изменения бесплатности, кэша, lifetime, error classifier, route health, UI, настроек, локализации, документации и сетевого клиента. Они могут быть рассмотрены только отдельными задачами с отдельным разрешением пользователя. + +## Interfaces and Dependencies + +Новые внешние зависимости и NuGet-пакеты не требуются. + +Существующие интерфейсы остаются без изменений: + + AiGateway.GenerateAsync(AiChatRequest, CancellationToken) + AiGateway.GenerateStreamingAsync(AiChatRequest, CancellationToken) + AiGateway.GetModelsAsync(AiConnectionSettings, CancellationToken) + AiChatRequest + AiGatewayResponse + AiGatewayStream + AiConnectionSettings + AiSettings + +Добавляется один внутренний opt-in entry point только для Text Processing: + + AiGateway.GenerateTextProcessingStreamingAsync( + AiChatRequest request, + CancellationToken cancellationToken) + +Он не заменяет и не меняет публичный `GenerateStreamingAsync`. + +Второй новый внутренний интерфейс появляется только если будет создан отдельный файл политики: + + AiModelSelectionPolicy.OrderRoutes( + AiSettings settings, + AiChatRequest request, + IEnumerable routes) + +Политика возвращает те же маршруты, что получила, без добавления и удаления; меняется только порядок. + +Plan revision note (2026-07-25): Первоначальный расширенный план признан слишком широким для разрешённой задачи. Пересмотренная версия вводит строгий allowlist файлов и меняет исключительно детерминированный порядок уже доступных моделей и ключей. Бесплатность, UI, настройки, каталоги, HTTP, streaming, ошибки, cache, runtime health и документация явно зафиксированы как неизменяемые. + +Plan revision note (2026-07-25): После проверки usages обнаружено, что `AiGateway` создаётся и экспортируется `MainWindow`. Для исключения косвенного влияния на другие функции новая политика переведена в opt-in метод `GenerateTextProcessingStreamingAsync`; публичные gateway entry points сохраняют legacy-порядок, а `TextProcessingWindow.xaml.cs` допускает ровно одну замену имени вызываемого метода. + +Plan revision note (2026-07-25): После экспертной проверки удалён дублирующий `BuildTextProcessingCandidates`; scoped-путь использует неизменённый общий `BuildCandidates` и восстанавливает стабильные ranks из настроек. Политика формально ограничена перестановкой того же multiset маршрутов, exact mode валидируется fail-closed, а изменения разрешённых грязных файлов сравниваются с точными baseline-копиями из временного каталога. + +Plan revision note (2026-07-25): После второй экспертной проверки оставлено одно окончательное трёхполевое объявление `AiRouteCandidate`; по фактическому коду закреплено, что automatic Text Processing не передаёт provider/model preference, а exact передаёт оба идентификатора. Добавлены stop-gates при отсутствии заявленных тестов или красном свежем baseline, полный пересчёт тестов вместо доверия историческим 897 и построчная ревизия `BuildRoutesAsync` с точным сохранением последовательности legacy-маршрутов. diff --git a/AiteBar.Tests/ActivationZoneHelperTests.cs b/AiteBar.Tests/ActivationZoneHelperTests.cs index 3ee9767..ae84f57 100644 --- a/AiteBar.Tests/ActivationZoneHelperTests.cs +++ b/AiteBar.Tests/ActivationZoneHelperTests.cs @@ -56,4 +56,40 @@ public void IsInActivationZone_RespectsCenteredPercentSpan() Assert.False(ActivationZoneHelper.IsInActivationZone( DockEdge.Top, 0, 0, 1000, 800, 20, 300, 0)); } + + [Fact] + public void ActivationDwell_StationaryPointerActivatesAfterDelay() + { + var tracker = new ActivationDwellTracker(); + DateTime start = DateTime.UtcNow; + + Assert.False(tracker.Update(true, 500, 0, start, delayMs: 200)); + Assert.False(tracker.Update(true, 502, 1, start.AddMilliseconds(199), delayMs: 200)); + Assert.True(tracker.Update(true, 501, 0, start.AddMilliseconds(200), delayMs: 200)); + } + + [Fact] + public void ActivationDwell_MovementAlongBrowserTabsKeepsResettingDelay() + { + var tracker = new ActivationDwellTracker(); + DateTime start = DateTime.UtcNow; + + Assert.False(tracker.Update(true, 500, 0, start, delayMs: 150)); + Assert.False(tracker.Update(true, 520, 0, start.AddMilliseconds(60), delayMs: 150)); + Assert.False(tracker.Update(true, 540, 0, start.AddMilliseconds(120), delayMs: 150)); + Assert.False(tracker.Update(true, 560, 0, start.AddMilliseconds(180), delayMs: 150)); + Assert.False(tracker.Update(true, 580, 0, start.AddMilliseconds(240), delayMs: 150)); + } + + [Fact] + public void ActivationDwell_LeavingZoneClearsPreviousProgress() + { + var tracker = new ActivationDwellTracker(); + DateTime start = DateTime.UtcNow; + + Assert.False(tracker.Update(true, 500, 0, start, delayMs: 150)); + Assert.False(tracker.Update(false, 500, 5, start.AddMilliseconds(100), delayMs: 150)); + Assert.False(tracker.Update(true, 500, 0, start.AddMilliseconds(160), delayMs: 150)); + Assert.True(tracker.Update(true, 500, 0, start.AddMilliseconds(310), delayMs: 150)); + } } diff --git a/AiteBar.Tests/AiProviderTests.cs b/AiteBar.Tests/AiProviderTests.cs new file mode 100644 index 0000000..cfb67c9 --- /dev/null +++ b/AiteBar.Tests/AiProviderTests.cs @@ -0,0 +1,412 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace AiteBar.Tests; + +public sealed class AiProviderTests +{ + [Fact] + public void Catalog_ContainsSupportedFreeTierProvidersAndExcludesDeepInfra() + { + string[] ids = AiProviderCatalog.All.Select(provider => provider.Id).ToArray(); + + Assert.Equal(["cerebras", "gemini", "groq", "mistral"], ids); + Assert.DoesNotContain("deepinfra", ids); + Assert.DoesNotContain("openrouter", ids); + Assert.DoesNotContain("github", ids); + Assert.All(AiProviderCatalog.All, provider => Assert.Equal("https", provider.ModelsUri.Scheme)); + } + + [Fact] + public void SettingsNormalizer_RejectsUnknownProvidersAndUnsafeCredentialTargets() + { + var settings = new AiSettings + { + FreeTierOnly = false, + ProviderOrder = ["unknown", "cerebras", "cerebras"], + Connections = + [ + Connection("valid", "cerebras", "AiteBar/AI/valid"), + Connection("unsafe", "cerebras", "OtherApp/credential"), + Connection("unknown", "missing", "AiteBar/AI/unknown") + ] + }; + + AiSettings normalized = AiSettingsNormalizer.Normalize(settings, out bool changed); + + Assert.True(changed); + Assert.True(normalized.FreeTierOnly); + Assert.Single(normalized.Connections); + Assert.Equal("valid", normalized.Connections[0].Id); + Assert.Equal("cerebras", normalized.ProviderOrder[0]); + Assert.Equal(AiProviderCatalog.DefaultProviderOrder.Count, normalized.ProviderOrder.Count); + Assert.Equal(normalized.ProviderOrder.Count, normalized.ProviderOrder.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public async Task Gateway_ExactModel_TriesNextConnectionAfterRateLimit() + { + var credentials = new MemoryCredentialStore(); + credentials.Write("AiteBar/AI/one", "key-one"); + credentials.Write("AiteBar/AI/two", "key-two"); + + var handler = new RoutingHandler(); + var client = new AiProviderClient(new HttpClient(handler), credentials); + var settingsService = new AppSettingsService(); + settingsService.Settings = new AppSettings + { + Ai = new AiSettings + { + FreeTierOnly = true, + ProviderOrder = ["cerebras"], + Connections = + [ + Connection("one", "cerebras", "AiteBar/AI/one"), + Connection("two", "cerebras", "AiteBar/AI/two") + ] + } + }; + var gateway = new AiGateway(settingsService, client, TimeProvider.System); + + AiGatewayResponse response = await gateway.GenerateAsync(new AiChatRequest + { + Messages = [new AiChatMessage("user", "hello")], + PreferredProviderId = "cerebras", + PreferredModelId = "cerebras-llama-3.3-70b", + RequireExactModel = true + }); + + Assert.Equal("ok", response.Content); + Assert.Equal("two", response.ConnectionId); + Assert.Contains("key-one", handler.SeenKeys); + Assert.Contains("key-two", handler.SeenKeys); + Assert.Equal(AiConnectionState.CoolingDown, + gateway.GetQuotaStatus( + settingsService.Settings.Ai.Connections[0], + "cerebras-llama-3.3-70b")?.State); + } + + [Fact] + public async Task Gateway_AutomaticMode_ExhaustsSameModelRoutesBeforeChangingModel() + { + var credentials = new MemoryCredentialStore(); + credentials.Write("AiteBar/AI/one", "key-one"); + credentials.Write("AiteBar/AI/two", "key-two"); + var handler = new ModelFirstRoutingHandler(); + var client = new AiProviderClient(new HttpClient(handler), credentials); + var settingsService = new AppSettingsService + { + Settings = new AppSettings + { + Ai = new AiSettings + { + FreeTierOnly = true, + ProviderOrder = ["cerebras"], + Connections = + [ + Connection("one", "cerebras", "AiteBar/AI/one"), + Connection("two", "cerebras", "AiteBar/AI/two") + ] + } + } + }; + settingsService.Settings.Ai.Connections[1].PreferredModelId = "model-b"; + var gateway = new AiGateway(settingsService, client, TimeProvider.System); + + AiGatewayResponse response = await gateway.GenerateAsync(new AiChatRequest + { + Messages = [new AiChatMessage("user", "hello")], + RequireFreeModel = true + }); + + Assert.Equal("model-a", response.ModelId); + Assert.Equal(["key-one:model-a", "key-two:model-a"], handler.GenerationAttempts); + } + + [Fact] + public void Gateway_RequestRequiringFreeModel_ExcludesPaidModelsRegardlessOfGlobalSetting() + { + var settings = new AiSettings { FreeTierOnly = false }; + AiConnectionSettings connection = Connection("one", "cerebras", "AiteBar/AI/one"); + AiModelDescriptor paid = Model("paid", AiCostStatus.Paid); + AiModelDescriptor free = Model("free", AiCostStatus.VerifiedFree); + var request = new AiChatRequest { RequireFreeModel = true }; + + AiModelDescriptor? selected = AiGateway.SelectModel( + settings, + connection, + [paid, free], + request); + AiModelDescriptor? unavailable = AiGateway.SelectModel( + settings, + connection, + [paid], + request); + + Assert.Same(free, selected); + Assert.Null(unavailable); + } + + [Fact] + public void Gateway_RequestRequiringWritingModel_ExcludesNonWritingModels() + { + var settings = new AiSettings(); + AiConnectionSettings connection = Connection("one", "cerebras", "AiteBar/AI/one"); + AiModelDescriptor audio = Model("speech-to-text", AiCostStatus.VerifiedFree); + AiModelDescriptor writing = Model("writer", AiCostStatus.VerifiedFree); + var request = new AiChatRequest + { + RequireFreeModel = true, + RequireWritingModel = true + }; + + AiModelDescriptor? selected = AiGateway.SelectModel( + settings, + connection, + [audio, writing], + request); + + Assert.Same(writing, selected); + } + + [Fact] + public void Gateway_ExactSelection_UsesAllRequestedProviderConnectionsAndNeverChangesModel() + { + var settingsService = new AppSettingsService(); + AiConnectionSettings first = Connection("first", "cerebras", "AiteBar/AI/first"); + AiConnectionSettings second = Connection("second", "cerebras", "AiteBar/AI/second"); + AiConnectionSettings otherProvider = Connection("other-provider", "groq", "AiteBar/AI/other-provider"); + var settings = new AiSettings + { + Connections = [first, second, otherProvider] + }; + var gateway = new AiGateway(settingsService); + var request = new AiChatRequest + { + PreferredProviderId = "cerebras", + PreferredModelId = "wanted", + RequireExactModel = true, + RequireFreeModel = true + }; + + IReadOnlyList candidates = gateway.BuildCandidates(settings, request); + AiModelDescriptor? missing = AiGateway.SelectModel( + settings, + second, + [Model("other", AiCostStatus.VerifiedFree)], + request); + AiModelDescriptor wanted = Model("wanted", AiCostStatus.VerifiedFree); + AiModelDescriptor? selected = AiGateway.SelectModel( + settings, + second, + [Model("other", AiCostStatus.VerifiedFree), wanted], + request); + second.PreferredModelId = "other"; + AiModelDescriptor? noExplicitModel = AiGateway.SelectModel( + settings, + second, + [Model("other", AiCostStatus.VerifiedFree)], + new AiChatRequest { RequireExactModel = true }); + + Assert.Collection( + candidates, + connection => Assert.Equal("first", connection.Id), + connection => Assert.Equal("second", connection.Id)); + Assert.Null(missing); + Assert.Same(wanted, selected); + Assert.Null(noExplicitModel); + } + + [Fact] + public async Task Gateway_InvalidatingModelCache_ForcesNextCatalogueRequest() + { + var credentials = new MemoryCredentialStore(); + credentials.Write("AiteBar/AI/one", "key-one"); + var handler = new RoutingHandler(); + var client = new AiProviderClient(new HttpClient(handler), credentials); + var settingsService = new AppSettingsService(); + AiConnectionSettings connection = Connection("one", "cerebras", "AiteBar/AI/one"); + var gateway = new AiGateway(settingsService, client, TimeProvider.System); + + await gateway.GetModelsAsync(connection); + await gateway.GetModelsAsync(connection); + Assert.Equal(1, handler.ModelRequestCount); + + gateway.InvalidateModelCache(connection.Id); + await gateway.GetModelsAsync(connection); + + Assert.Equal(2, handler.ModelRequestCount); + } + + [Fact] + public async Task SettingsService_DeepClonesAiMetadataWithoutAnyApiKeyProperty() + { + string root = Path.Combine(Path.GetTempPath(), $"aitebar-ai-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + string settingsPath = Path.Combine(root, "settings.json"); + var service = new AppSettingsService(Path.Combine(root, "legacy.json"), settingsPath); + service.Settings = new AppSettings + { + Ai = new AiSettings + { + Connections = [Connection("id", "cerebras", "AiteBar/AI/id")] + } + }; + + AppSettings snapshot = service.Settings; + snapshot.Ai.Connections[0].DisplayName = "mutated"; + await service.SaveAsync(); + string json = await File.ReadAllTextAsync(settingsPath); + + Assert.NotEqual("mutated", service.Settings.Ai.Connections[0].DisplayName); + Assert.DoesNotContain("ApiKey", json, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AiteBar/AI/id", json, StringComparison.Ordinal); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SettingsWindow_ContainsAiSectionAndCredentialLifecycleHandlers() + { + string repoRoot = FindRepoRoot(); + string xaml = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml")); + string code = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml.cs")); + + Assert.Contains("x:Name=\"AiProvidersSettingsSection\"", xaml); + Assert.Contains("x:Name=\"AiConnectionsList\"", xaml); + Assert.Contains("BtnAiAddConnection_Click", xaml); + Assert.Contains("CleanupPendingAiCredentials", code); + Assert.Contains("CommitAiCredentialChanges", code); + Assert.Contains("new WindowsAiCredentialStore()", code); + } + + [Fact] + public void ConnectionDialog_LinksToSelectedProvidersOfficialKeyPage() + { + string repoRoot = FindRepoRoot(); + string xaml = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AiConnectionDialog.xaml")); + string code = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AiConnectionDialog.xaml.cs")); + + Assert.Contains("ResourceKey=AiSettings_GetApiKey", xaml); + Assert.Contains("LinkGetApiKey_Click", xaml); + Assert.Contains("provider.DocumentationUri.AbsoluteUri", code); + } + + private static AiConnectionSettings Connection( + string id, + string providerId, + string target) => new() + { + Id = id, + ProviderId = providerId, + DisplayName = id, + CredentialTarget = target, + IsEnabled = true + }; + + private static AiModelDescriptor Model(string id, AiCostStatus costStatus) => new( + "cerebras", + id, + id, + AiCapabilities.Text, + 32_000, + costStatus); + + private sealed class MemoryCredentialStore : IAiCredentialStore + { + private readonly Dictionary _secrets = new(StringComparer.Ordinal); + public void Write(string target, string secret) => _secrets[target] = secret; + public string? Read(string target) => _secrets.GetValueOrDefault(target); + public bool Delete(string target) => _secrets.Remove(target); + } + + private sealed class RoutingHandler : HttpMessageHandler + { + public List SeenKeys { get; } = []; + public int ModelRequestCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + string key = request.Headers.Authorization?.Parameter ?? string.Empty; + SeenKeys.Add(key); + if (request.Method == HttpMethod.Get) + { + ModelRequestCount++; + return Task.FromResult(Json(HttpStatusCode.OK, + "{\"data\":[{\"id\":\"cerebras-llama-3.3-70b\",\"name\":\"Llama 3.3 70B\"}]}")); + } + if (key == "key-one") + { + var limited = Json(HttpStatusCode.TooManyRequests, "{\"error\":{\"message\":\"limited\"}}"); + limited.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromMinutes(5)); + return Task.FromResult(limited); + } + return Task.FromResult(Json(HttpStatusCode.OK, + "{\"choices\":[{\"message\":{\"content\":\"ok\"}}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}")); + } + + private static HttpResponseMessage Json(HttpStatusCode statusCode, string json) => new(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + private sealed class ModelFirstRoutingHandler : HttpMessageHandler + { + public List GenerationAttempts { get; } = []; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + string key = request.Headers.Authorization?.Parameter ?? string.Empty; + if (request.Method == HttpMethod.Get) + { + return Json(HttpStatusCode.OK, + "{\"data\":[" + + "{\"id\":\"model-a\",\"name\":\"Model A\"}," + + "{\"id\":\"model-b\",\"name\":\"Model B\"}]}"); + } + + string payload = await request.Content!.ReadAsStringAsync(cancellationToken); + using JsonDocument document = JsonDocument.Parse(payload); + string modelId = document.RootElement.GetProperty("model").GetString() ?? string.Empty; + GenerationAttempts.Add($"{key}:{modelId}"); + if (key == "key-one" && modelId == "model-a") + { + return Json(HttpStatusCode.TooManyRequests, "{\"error\":{\"message\":\"limited\"}}"); + } + return Json(HttpStatusCode.OK, + $"{{\"model\":\"{modelId}\",\"choices\":[{{\"message\":{{\"content\":\"ok\"}}}}]}}"); + } + + private static HttpResponseMessage Json(HttpStatusCode statusCode, string json) => new(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + private static string FindRepoRoot() + { + string? current = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(current)) + { + if (File.Exists(Path.Combine(current, "AiteBar.sln"))) + { + return current; + } + current = Directory.GetParent(current)?.FullName; + } + throw new DirectoryNotFoundException("Repository root with AiteBar.sln was not found."); + } +} diff --git a/AiteBar.Tests/AiStreamingTests.cs b/AiteBar.Tests/AiStreamingTests.cs new file mode 100644 index 0000000..db57139 --- /dev/null +++ b/AiteBar.Tests/AiStreamingTests.cs @@ -0,0 +1,289 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; + +namespace AiteBar.Tests; + +public sealed class AiStreamingTests +{ + [Fact] + public void RequestTimeout_AllowsLongStreamingGeneration() + { + Assert.Equal(TimeSpan.FromMinutes(5), AiProviderClient.RequestTimeout); + Assert.True(AiProviderClient.RequestTimeout > AiProviderClient.StreamInactivityTimeout); + } + + [Fact] + public void ParseOpenAiStreamData_ReturnsContentDelta() + { + string? content = AiProviderClient.ParseOpenAiStreamData( + "data: {\"choices\":[{\"delta\":{\"content\":\"исправ\"}}]}"); + + Assert.Equal("исправ", content); + Assert.Null(AiProviderClient.ParseOpenAiStreamData("data: [DONE]")); + } + + [Fact] + public void ParseGeminiStreamData_ReturnsCandidateParts() + { + string? content = AiProviderClient.ParseGeminiStreamData( + "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ис\"},{\"text\":\"прав\"}]}}]}"); + + Assert.Equal("исправ", content); + } + + [Fact] + public void StreamingPreview_HidesPartialMarkerAndRestoresCompleteMarker() + { + var service = new TextProcessingService(); + ProtectedText protectedText = service.ProtectTechnicalFragments("До https://example.com после"); + string marker = Assert.Single(protectedText.Fragments).Key; + string prefix = protectedText.Text[..protectedText.Text.IndexOf(marker, StringComparison.Ordinal)]; + + Assert.Equal( + prefix, + TextProcessingWindow.BuildStreamingPreview(prefix + marker[..10], protectedText)); + Assert.Equal( + "До https://example.com после", + TextProcessingWindow.BuildStreamingPreview(protectedText.Text, protectedText)); + } + + [Fact] + public async Task OpenAiCompatibleStreaming_SendsStreamingRequestAndReadsCompleteHttpBody() + { + var credentials = new MemoryCredentialStore(); + credentials.Write("AiteBar/AI/test", "secret"); + var handler = new DelegateHandler(async request => + { + Assert.Equal("Bearer", request.Headers.Authorization?.Scheme); + Assert.Equal("secret", request.Headers.Authorization?.Parameter); + string payload = await request.Content!.ReadAsStringAsync(); + Assert.Contains("\"stream\":true", payload, StringComparison.Ordinal); + return Sse( + "data: {\"choices\":[{\"delta\":{\"content\":\"ис\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"content\":\"прав\"}}]}\n\n" + + "data: [DONE]\n\n"); + }); + var client = new AiProviderClient(new HttpClient(handler), credentials); + + AiProviderStream stream = await client.GenerateStreamingAsync( + Connection("cerebras"), + Model("cerebras"), + Request(), + CancellationToken.None); + + Assert.Equal("исправ", await CollectAsync(stream.Chunks)); + } + + [Fact] + public async Task GeminiStreaming_UsesSseEndpointAndReadsCompleteHttpBody() + { + var credentials = new MemoryCredentialStore(); + credentials.Write("AiteBar/AI/test", "secret"); + var handler = new DelegateHandler(async request => + { + Assert.Contains(":streamGenerateContent", request.RequestUri!.AbsoluteUri, StringComparison.Ordinal); + Assert.Contains("alt=sse", request.RequestUri.Query, StringComparison.Ordinal); + string payload = await request.Content!.ReadAsStringAsync(); + Assert.Contains("\"systemInstruction\"", payload, StringComparison.Ordinal); + return Sse( + "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ис\"}]}}]}\n\n" + + "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"прав\"}]}}]}\n\n"); + }); + var client = new AiProviderClient(new HttpClient(handler), credentials); + + AiProviderStream stream = await client.GenerateStreamingAsync( + Connection("gemini"), + Model("gemini"), + Request(), + CancellationToken.None); + + Assert.Equal("исправ", await CollectAsync(stream.Chunks)); + } + + [Fact] + public async Task Gateway_MarksConnectionSuccessfulOnlyAfterStreamCompletes() + { + var credentials = new MemoryCredentialStore(); + credentials.Write("AiteBar/AI/test", "secret"); + var handler = new DelegateHandler(request => Task.FromResult( + request.Method == HttpMethod.Get + ? Json("{\"data\":[{\"id\":\"writer\",\"name\":\"Writer\"}]}") + : Sse("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))); + var client = new AiProviderClient(new HttpClient(handler), credentials); + var settings = Settings("cerebras"); + var gateway = new AiGateway(settings, client, TimeProvider.System); + + AiGatewayStream stream = await gateway.GenerateStreamingAsync(Request()); + + Assert.NotEqual(AiConnectionState.Available, gateway.GetConnectionStatus("test")?.State); + Assert.Equal("ok", await CollectAsync(stream.Chunks)); + Assert.Equal(AiConnectionState.Available, gateway.GetConnectionStatus("test")?.State); + } + + [Fact] + public async Task Gateway_MarksConnectionUnavailableWhenStartedStreamFails() + { + var credentials = new MemoryCredentialStore(); + credentials.Write("AiteBar/AI/test", "secret"); + var handler = new DelegateHandler(request => Task.FromResult( + request.Method == HttpMethod.Get + ? Json("{\"data\":[{\"id\":\"writer\",\"name\":\"Writer\"}]}") + : new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new FailingAfterFirstReadStream( + "data: {\"choices\":[{\"delta\":{\"content\":\"first\"}}]}\n\n")) + })); + var client = new AiProviderClient(new HttpClient(handler), credentials); + var settings = Settings("cerebras"); + var gateway = new AiGateway(settings, client, TimeProvider.System); + AiGatewayStream stream = await gateway.GenerateStreamingAsync(Request()); + await using IAsyncEnumerator enumerator = stream.Chunks.GetAsyncEnumerator(); + + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal("first", enumerator.Current); + await Assert.ThrowsAsync(() => enumerator.MoveNextAsync().AsTask()); + Assert.Equal(AiConnectionState.Unavailable, gateway.GetConnectionStatus("test")?.State); + } + + [Fact] + public async Task StreamRead_ThrowsTimeoutAfterConfiguredInactivity() + { + await Assert.ThrowsAsync(() => + AiProviderClient.ReadLineWithInactivityTimeoutAsync( + new BlockingTextReader(), + TimeSpan.FromMilliseconds(20), + CancellationToken.None)); + } + + private static AiChatRequest Request() => new() + { + Messages = + [ + new AiChatMessage("system", "instruction"), + new AiChatMessage("user", "text") + ], + RequireFreeModel = true, + RequireWritingModel = true + }; + + private static AiConnectionSettings Connection(string providerId) => new() + { + Id = "test", + ProviderId = providerId, + DisplayName = "Test", + CredentialTarget = "AiteBar/AI/test", + IsEnabled = true + }; + + private static AiModelDescriptor Model(string providerId) => new( + providerId, + "writer", + "Writer", + AiCapabilities.Text, + 32_000, + AiCostStatus.VerifiedFree); + + private static AppSettingsService Settings(string providerId) + { + var settings = new AppSettingsService(); + settings.Settings = new AppSettings + { + Ai = new AiSettings + { + Connections = [Connection(providerId)], + ProviderOrder = [providerId] + } + }; + return settings; + } + + private static async Task CollectAsync(IAsyncEnumerable chunks) + { + var result = new StringBuilder(); + await foreach (string chunk in chunks) + { + result.Append(chunk); + } + return result.ToString(); + } + + private static HttpResponseMessage Sse(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/event-stream") + }; + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + + private sealed class MemoryCredentialStore : IAiCredentialStore + { + private readonly Dictionary _secrets = new(StringComparer.Ordinal); + public void Write(string target, string secret) => _secrets[target] = secret; + public string? Read(string target) => _secrets.GetValueOrDefault(target); + public bool Delete(string target) => _secrets.Remove(target); + } + + private sealed class DelegateHandler( + Func> handler) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => handler(request); + } + + private sealed class BlockingTextReader : TextReader + { + public override async ValueTask ReadLineAsync(CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return null; + } + } + + private sealed class FailingAfterFirstReadStream(string firstRead) : Stream + { + private readonly byte[] _firstRead = Encoding.UTF8.GetBytes(firstRead); + private int _position; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _firstRead.Length; + public override long Position { get => _position; set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_position >= _firstRead.Length) + { + throw new IOException("stream failed"); + } + int length = Math.Min(count, _firstRead.Length - _position); + Array.Copy(_firstRead, _position, buffer, offset, length); + _position += length; + return length; + } + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (_position >= _firstRead.Length) + { + return ValueTask.FromException(new IOException("stream failed")); + } + int length = Math.Min(buffer.Length, _firstRead.Length - _position); + _firstRead.AsMemory(_position, length).CopyTo(buffer); + _position += length; + return ValueTask.FromResult(length); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/AiteBar.Tests/AppSettingsDiscreteChoiceHelperTests.cs b/AiteBar.Tests/AppSettingsDiscreteChoiceHelperTests.cs new file mode 100644 index 0000000..b3d2e7a --- /dev/null +++ b/AiteBar.Tests/AppSettingsDiscreteChoiceHelperTests.cs @@ -0,0 +1,95 @@ +namespace AiteBar.Tests; + +public sealed class AppSettingsDiscreteChoiceHelperTests +{ + [Theory] + [InlineData(50, 0)] + [InlineData(70, 1)] + [InlineData(90, 2)] + [InlineData(100, 3)] + [InlineData(82, 2)] + public void GetNearestIndex_ReturnsExpectedPanelSizeIndex(double value, int expected) + { + Assert.Equal(expected, AppSettingsDiscreteChoiceHelper.GetNearestIndex( + AppSettingsDiscreteChoiceHelper.PanelSizeValues, + value)); + } + + [Theory] + [InlineData(10, 0)] + [InlineData(30, 1)] + [InlineData(50, 2)] + [InlineData(100, 3)] + [InlineData(42, 2)] + public void GetNearestIndex_ReturnsExpectedActivationZoneIndex(double value, int expected) + { + Assert.Equal(expected, AppSettingsDiscreteChoiceHelper.GetNearestIndex( + AppSettingsDiscreteChoiceHelper.ActivationZoneValues, + value)); + } + + [Theory] + [InlineData(-2, 100)] + [InlineData(0, 100)] + [InlineData(1, 200)] + [InlineData(2, 300)] + [InlineData(3, 500)] + [InlineData(8, 500)] + public void GetValue_ClampsAndMapsActivationDelayIndex(double index, int expected) + { + Assert.Equal(expected, AppSettingsDiscreteChoiceHelper.GetValue( + AppSettingsDiscreteChoiceHelper.ActivationDelayValues, + index)); + } + + [Fact] + public void EmptyChoices_ReturnSafeDefaults() + { + Assert.Equal(0, AppSettingsDiscreteChoiceHelper.GetNearestIndex([], 100)); + Assert.Equal(0, AppSettingsDiscreteChoiceHelper.GetValue([], 2)); + } + + [Theory] + [InlineData(80, 1)] // equidistant from 70 (idx 1) and 90 (idx 2), picks first (70) + [InlineData(60, 0)] // equidistant from 50 (idx 0) and 70 (idx 1), picks first (50) + public void GetNearestIndex_TieBreaksToFirstMatch(double value, int expected) + { + Assert.Equal(expected, AppSettingsDiscreteChoiceHelper.GetNearestIndex( + AppSettingsDiscreteChoiceHelper.PanelSizeValues, + value)); + } + + [Theory] + [InlineData(65, 70)] // non-standard -> snaps to 70 + [InlineData(85, 90)] // non-standard -> snaps to 90 + [InlineData(55, 50)] // non-standard -> snaps to 50 + [InlineData(92, 90)] // closer to 90 than 100 + public void GetNearestIndex_NormalizesNonStandardPanelSizeValues(double input, int expectedValue) + { + int index = AppSettingsDiscreteChoiceHelper.GetNearestIndex( + AppSettingsDiscreteChoiceHelper.PanelSizeValues, input); + Assert.Equal(expectedValue, AppSettingsDiscreteChoiceHelper.PanelSizeValues[index]); + } + + [Theory] + [InlineData(20, 10)] // non-standard -> snaps to 10 + [InlineData(40, 30)] // non-standard -> snaps to 30 + [InlineData(60, 50)] // non-standard -> snaps to 50 + public void GetNearestIndex_NormalizesNonStandardActivationZoneValues(double input, int expectedValue) + { + int index = AppSettingsDiscreteChoiceHelper.GetNearestIndex( + AppSettingsDiscreteChoiceHelper.ActivationZoneValues, input); + Assert.Equal(expectedValue, AppSettingsDiscreteChoiceHelper.ActivationZoneValues[index]); + } + + [Theory] + [InlineData(150, 100)] // closer to 100 than 200 + [InlineData(250, 200)] // closer to 200 than 300 + [InlineData(350, 300)] // closer to 300 than 500 + public void GetNearestIndex_NormalizesNonStandardActivationDelayValues(double input, int expectedValue) + { + int index = AppSettingsDiscreteChoiceHelper.GetNearestIndex( + AppSettingsDiscreteChoiceHelper.ActivationDelayValues, input); + Assert.Equal(expectedValue, AppSettingsDiscreteChoiceHelper.ActivationDelayValues[index]); + } +} diff --git a/AiteBar.Tests/AppSettingsLayoutContractTests.cs b/AiteBar.Tests/AppSettingsLayoutContractTests.cs new file mode 100644 index 0000000..e375ea4 --- /dev/null +++ b/AiteBar.Tests/AppSettingsLayoutContractTests.cs @@ -0,0 +1,381 @@ +using System.IO; +using System.Xml.Linq; + +namespace AiteBar.Tests; + +public sealed class AppSettingsLayoutContractTests +{ + private static readonly XNamespace XamlNamespace = "http://schemas.microsoft.com/winfx/2006/xaml"; + private static readonly XNamespace PresentationNamespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + + private static readonly string[] SettingsControlNames = + [ + "CmbLanguage", + "SegEdgeTop", "SegEdgeBottom", "SegEdgeLeft", "SegEdgeRight", + "SliderPanelSize", "LblPanelSize50", "LblPanelSize70", "LblPanelSize90", "LblPanelSize100", + "SliderActivationZone", "LblActivationZone10", "LblActivationZone30", "LblActivationZone50", "LblActivationZone100", + "SliderActivationDelay", "LblActivationDelay100", "LblActivationDelay200", "LblActivationDelay300", "LblActivationDelay500", + "TxtAboutVersion", "SettingsFooter", "BtnKeepOnTop", "AiConnectionsList", "TxtAiConnectionsEmpty", + "ChkShowTaskbarPositionIndicator", "ChkSecondaryMonitor", "ChkCheckForUpdatesEnabled", + "PanelContextsList", + "HotkeyShowPanel", "HotkeyNextContext", "HotkeyPreviousContext", "HotkeyAddButton", + "HotkeyFileSorter", "HotkeyIconConverter", "HotkeyQuickNote", "HotkeyColorPicker", "HotkeyTimerStopwatch", "HotkeyQRCodeGenerator", "HotkeyClipboardManager", + "ChkShowPresetSearch", "ChkShowPresetScreenshot", "ChkShowPresetVideo", "ChkShowPresetCalc", + "ChkShowPresetExplorer", "ChkShowPresetDownloads", "ChkShowPresetFileSorter", "ChkShowPresetIconConverter", + "ChkShowPresetTimerStopwatch", "ChkShowPresetColorPicker", "ChkShowPresetQuickNote", "ChkShowPresetQRCodeGenerator", + "ChkShowPresetClipboardManager", "ChkShowPresetShowDesktop", "ChkShowPresetAppsFolder", "ChkShowPresetCopilot", + "ChkShowPresetTextProcessing", + "ChkClipboardManagerPersistHistory", "QuickToolsList", + "QuickToolRowQuickNote", "QuickToolRowQRCodeGenerator", "QuickToolRowDownloads", "QuickToolRowVideo", + "QuickToolRowCopilot", "QuickToolRowCalculator", "QuickToolRowIconConverter", "QuickToolRowClipboardManager", + "QuickToolRowColorPicker", "QuickToolRowSearch", "QuickToolRowShowDesktop", "QuickToolRowAppsFolder", + "QuickToolRowExplorer", "QuickToolRowScreenshot", "QuickToolRowFileSorter", "QuickToolRowTimerStopwatch", + "QuickToolRowTextProcessing" + ]; + + [Fact] + public void SettingsInventory_ContainsEveryExistingNamedControlExactlyOnce() + { + XDocument window = LoadWindow(); + + foreach (string controlName in SettingsControlNames) + { + Assert.Single( + window.Descendants(), + element => string.Equals(element.Attribute(XamlNamespace + "Name")?.Value, controlName, StringComparison.Ordinal)); + } + } + + [Fact] + public void ExistingSettingsHandlers_RemainWired() + { + XDocument window = LoadWindow(); + + AssertHandlerCount(window, "CmbLanguage_SelectionChanged", 1); + AssertHandlerCount(window, "SegEdge_Click", 4); + AssertHandlerCount(window, "SliderPanelSize_ValueChanged", 1); + AssertHandlerCount(window, "SliderActivationZone_ValueChanged", 1); + AssertHandlerCount(window, "SliderActivationDelay_ValueChanged", 1); + AssertHandlerCount(window, "BtnKeepOnTop_Click", 1); + AssertHandlerCount(window, "BtnCancel_Click", 1); + AssertHandlerCount(window, "BtnSave_Click", 1); + } + + [Fact] + public void ModernControls_ReplaceLegacyEditorsAndPreserveStagedSwitches() + { + XDocument window = LoadWindow(); + + Assert.Single(window.Descendants(PresentationNamespace + "ComboBox"), + element => element.Attribute(XamlNamespace + "Name")?.Value == "CmbLanguage"); + Assert.Equal(3, window.Descendants(PresentationNamespace + "Slider").Count()); + Assert.DoesNotContain(window.Descendants(), element => + element.Attribute(XamlNamespace + "Name")?.Value is "TxtPanelSizeValue" or "TxtActivationZoneValue" or "TxtActivationDelayValue"); + Assert.DoesNotContain(window.Descendants(PresentationNamespace + "Style"), element => + element.Attribute(XamlNamespace + "Key")?.Value is "ModernValueChipStyle" or "ModernValueChipTextStyle"); + Assert.Equal(11, window.Descendants().Count(element => element.Name.LocalName == "HotkeyCaptureBox")); + Assert.DoesNotContain(window.Descendants(), element => + (element.Attribute(XamlNamespace + "Name")?.Value ?? string.Empty).EndsWith("Key", StringComparison.Ordinal)); + + string[] staticSwitchNames = SettingsControlNames + .Where(name => name.StartsWith("Chk", StringComparison.Ordinal)) + .ToArray(); + foreach (string switchName in staticSwitchNames) + { + Assert.Equal("{StaticResource ModernSwitchStyle}", FindNamedElement(window, switchName).Attribute("Style")?.Value); + } + } + + [Fact] + public void Layout_UsesComfortableResizableWindowAndOneScrollablePageWithoutTabs() + { + XDocument window = LoadWindow(); + XElement root = Assert.IsType(window.Root); + + Assert.Equal("1200", root.Attribute("Width")?.Value); + Assert.Equal("680", root.Attribute("Height")?.Value); + Assert.Equal("960", root.Attribute("MinWidth")?.Value); + Assert.Equal("600", root.Attribute("MinHeight")?.Value); + Assert.Equal("CanResize", root.Attribute("ResizeMode")?.Value); + Assert.Equal("False", root.Attribute("Topmost")?.Value); + Assert.Equal("True", root.Attribute("ShowInTaskbar")?.Value); + Assert.Equal("CenterScreen", root.Attribute("WindowStartupLocation")?.Value); + Assert.Empty(window.Descendants(PresentationNamespace + "TabControl")); + Assert.Empty(window.Descendants(PresentationNamespace + "TabItem")); + + XElement layoutRoot = FindNamedElement(window, "SettingsLayoutRoot"); + Assert.Equal("16", layoutRoot.Attribute("Margin")?.Value); + Assert.Equal(["255", "24", "*"], layoutRoot + .Element(PresentationNamespace + "Grid.ColumnDefinitions")! + .Elements(PresentationNamespace + "ColumnDefinition") + .Select(column => column.Attribute("Width")?.Value ?? string.Empty) + .ToArray()); + XElement contentHost = FindNamedElement(window, "SettingsContentHost"); + Assert.Equal("2", contentHost.Attribute("Grid.Column")?.Value); + Assert.Equal("2", contentHost.Attribute("Grid.RowSpan")?.Value); + Assert.Equal("1000", contentHost.Attribute("MaxWidth")?.Value); + Assert.Equal("Stretch", contentHost.Attribute("HorizontalAlignment")?.Value); + Assert.Equal("0", FindNamedElement(window, "SettingsNavigationPanel").Attribute("Padding")?.Value); + XElement navigationPanel = FindNamedElement(window, "SettingsNavigationPanel"); + Assert.Equal("Transparent", navigationPanel.Attribute("Background")?.Value); + Assert.Equal("0", navigationPanel.Attribute("BorderThickness")?.Value); + + XElement navigation = FindNamedElement(window, "SettingsNavigationList"); + XElement[] navigationItems = navigation.Elements(PresentationNamespace + "ListBoxItem").ToArray(); + Assert.Equal(6, navigationItems.Length); + Assert.Equal(["\uF587", "\uE8B0", "\uF4B8", "\uF82E", "\uF15A", "\uF4A2"], + navigationItems.Select(item => item.Attribute("Tag")?.Value ?? string.Empty).ToArray()); + Assert.All(navigationItems, item => + { + Assert.NotNull(item.Attribute("ToolTip")); + Assert.NotNull(item.Attribute("AutomationProperties.Name")); + }); + + XElement scrollViewer = FindNamedElement(window, "SettingsScrollViewer"); + Assert.Single(window.Descendants(PresentationNamespace + "ScrollViewer")); + Assert.Empty(scrollViewer.Descendants(PresentationNamespace + "ScrollViewer")); + + string[] expectedSections = + [ + "GeneralSettingsSection", + "ContextSettingsSection", + "HotkeySettingsSection", + "QuickToolsSettingsSection", + "AiProvidersSettingsSection", + "AboutSettingsSection" + ]; + string[] actualSections = scrollViewer + .Descendants(PresentationNamespace + "Border") + .Select(element => element.Attribute(XamlNamespace + "Name")?.Value) + .Where(name => expectedSections.Contains(name, StringComparer.Ordinal)) + .Cast() + .ToArray(); + Assert.Equal(expectedSections, actualSections); + + XElement scrollContent = FindNamedElement(window, "SettingsScrollContent"); + Assert.Equal("0,0,14,0", scrollContent.Attribute("Margin")?.Value); + string[] directSectionChildren = scrollContent + .Elements(PresentationNamespace + "Border") + .Select(element => element.Attribute(XamlNamespace + "Name")?.Value) + .Where(name => expectedSections.Contains(name, StringComparer.Ordinal)) + .Cast() + .ToArray(); + Assert.Equal(expectedSections, directSectionChildren); + Assert.Equal(["0,0,0,32", "0,0,0,32", "0,0,0,32", "0,0,0,32", "0,0,0,32", "0"], scrollContent + .Elements(PresentationNamespace + "Border") + .Where(element => expectedSections.Contains(element.Attribute(XamlNamespace + "Name")?.Value, StringComparer.Ordinal)) + .Select(element => element.Attribute("Margin")?.Value ?? string.Empty) + .ToArray()); + + XElement clipboardPersistence = FindNamedElement(window, "ChkClipboardManagerPersistHistory"); + XElement generalSection = FindNamedElement(window, "GeneralSettingsSection"); + XElement quickToolsSection = FindNamedElement(window, "QuickToolsSettingsSection"); + Assert.Contains(clipboardPersistence, generalSection.Descendants()); + Assert.DoesNotContain(clipboardPersistence, quickToolsSection.Descendants()); + + XElement cancel = FindNamedElement(window, "BtnCancel"); + XElement save = FindNamedElement(window, "BtnSave"); + XElement keepOnTop = FindNamedElement(window, "BtnKeepOnTop"); + XElement footer = FindNamedElement(window, "SettingsFooter"); + Assert.DoesNotContain(cancel, scrollViewer.Descendants()); + Assert.DoesNotContain(save, scrollViewer.Descendants()); + Assert.DoesNotContain(keepOnTop, scrollViewer.Descendants()); + Assert.Equal("1", footer.Attribute("Grid.Row")?.Value); + Assert.Equal("False", keepOnTop.Attribute("IsChecked")?.Value); + Assert.Contains(keepOnTop, footer.Descendants()); + Assert.Contains(cancel, footer.Descendants()); + Assert.Contains(save, footer.Descendants()); + Assert.Contains(cancel, contentHost.Descendants()); + Assert.Contains(save, contentHost.Descendants()); + } + + [Fact] + public void ModernSlider_UsesDiscreteTrackTicksAndInteractiveVisualStates() + { + XDocument resources = LoadXaml("FormControlsResources.xaml"); + XElement style = Assert.Single(resources.Descendants(PresentationNamespace + "Style"), + element => element.Attribute(XamlNamespace + "Key")?.Value == "BaseSliderStyle"); + + Assert.Contains(style.Elements(PresentationNamespace + "Setter"), setter => + setter.Attribute("Property")?.Value == "Height" && setter.Attribute("Value")?.Value == "32"); + Assert.Contains(style.Elements(PresentationNamespace + "Setter"), setter => + setter.Attribute("Property")?.Value == "IsMoveToPointEnabled" && setter.Attribute("Value")?.Value == "True"); + Assert.Single(style.Descendants(), element => element.Attribute(XamlNamespace + "Name")?.Value == "SliderTrackBackground"); + XElement activeTrack = Assert.Single(style.Descendants(PresentationNamespace + "ProgressBar"), + element => element.Attribute(XamlNamespace + "Name")?.Value == "SliderActiveTrack"); + Assert.Equal("13,0", activeTrack.Attribute("Margin")?.Value); + XElement track = Assert.Single(style.Descendants(PresentationNamespace + "Track"), + element => element.Attribute(XamlNamespace + "Name")?.Value == "PART_Track"); + Assert.Null(track.Attribute("Margin")); + XElement thumb = Assert.Single(track.Descendants(PresentationNamespace + "Thumb")); + Assert.Equal("26", thumb.Attribute("Width")?.Value); + Assert.Equal("26", thumb.Attribute("Height")?.Value); + Assert.Equal(4, style.Descendants().Count(element => + (element.Attribute(XamlNamespace + "Name")?.Value ?? string.Empty).StartsWith("SliderTick", StringComparison.Ordinal))); + Assert.Single(style.Descendants(), element => element.Attribute(XamlNamespace + "Name")?.Value == "SliderThumbHalo"); + Assert.Single(style.Descendants(), element => element.Attribute(XamlNamespace + "Name")?.Value == "SliderThumbCore"); + Assert.Contains(style.Descendants(PresentationNamespace + "Trigger"), trigger => + trigger.Attribute("Property")?.Value == "IsDragging" && trigger.Attribute("Value")?.Value == "True"); + } + + [Fact] + public void NavigationItems_RenderFluentIconsThroughTheSharedTemplate() + { + XDocument window = LoadWindow(); + XElement style = Assert.Single(window.Descendants(PresentationNamespace + "Style"), + element => element.Attribute(XamlNamespace + "Key")?.Value == "SettingsNavigationItemStyle"); + XElement icon = Assert.Single(style.Descendants(PresentationNamespace + "TextBlock"), + element => element.Attribute(XamlNamespace + "Name")?.Value == "NavigationIcon"); + + Assert.Contains(style.Elements(PresentationNamespace + "Setter"), setter => + setter.Attribute("Property")?.Value == "Height" && setter.Attribute("Value")?.Value == "36"); + Assert.Equal("{TemplateBinding Tag}", icon.Attribute("Text")?.Value); + Assert.Equal("pack://application:,,,/Resources/#FluentSystemIcons-Regular", icon.Attribute("FontFamily")?.Value); + Assert.Equal("18", icon.Attribute("FontSize")?.Value); + } + + [Fact] + public void AboutActions_UseContextualFluentIconsInsteadOfDiagonalTextArrows() + { + XDocument window = LoadWindow(); + XElement aboutSection = FindNamedElement(window, "AboutSettingsSection"); + Dictionary expectedGlyphs = new(StringComparer.Ordinal) + { + ["BtnAboutCheckUpdates_Click"] = "\uF190", + ["BtnAboutWebsite_Click"] = "\uF45A", + ["BtnAboutRepository_Click"] = "\uF2EF", + ["BtnAboutLicenses_Click"] = "\uE557", + ["BtnAboutOpenDataFolder_Click"] = "\uF42E", + ["BtnAboutOpenProgramFolder_Click"] = "\uF42E" + }; + + XElement[] actionButtons = aboutSection.Descendants(PresentationNamespace + "Button") + .Where(button => expectedGlyphs.ContainsKey(button.Attribute("Click")?.Value ?? string.Empty)) + .ToArray(); + Assert.Equal(expectedGlyphs.Count, actionButtons.Length); + Assert.DoesNotContain(aboutSection.Descendants(PresentationNamespace + "TextBlock"), text => + text.Attribute("Text")?.Value is "↗" or "›"); + + foreach (XElement button in actionButtons) + { + string handler = button.Attribute("Click")!.Value; + XElement icon = Assert.Single(button.Descendants(PresentationNamespace + "TextBlock"), text => + text.Attribute("FontFamily")?.Value == "pack://application:,,,/Resources/#FluentSystemIcons-Regular"); + Assert.Equal(expectedGlyphs[handler], icon.Attribute("Text")?.Value); + } + } + + [Fact] + public void SectionSurfaces_UseBorderlessWindowsStyleCards() + { + XDocument window = LoadWindow(); + + XElement cardStyle = Assert.Single(window.Descendants(PresentationNamespace + "Style"), + element => element.Attribute(XamlNamespace + "Key")?.Value == "ModernSettingsCardStyle"); + Assert.Contains(cardStyle.Elements(PresentationNamespace + "Setter"), setter => + setter.Attribute("Property")?.Value == "BorderThickness" && setter.Attribute("Value")?.Value == "0"); + Assert.Contains(cardStyle.Elements(PresentationNamespace + "Setter"), setter => + setter.Attribute("Property")?.Value == "BorderBrush" && setter.Attribute("Value")?.Value == "Transparent"); + + XElement selectionStyle = Assert.Single(window.Descendants(PresentationNamespace + "Style"), + element => element.Attribute(XamlNamespace + "Key")?.Value == "ModernSelectionCardStyle"); + Assert.Contains(selectionStyle.Elements(PresentationNamespace + "Setter"), setter => + setter.Attribute("Property")?.Value == "BorderThickness" && setter.Attribute("Value")?.Value == "0"); + Assert.Contains(selectionStyle.Elements(PresentationNamespace + "Setter"), setter => + setter.Attribute("Property")?.Value == "BorderBrush" && setter.Attribute("Value")?.Value == "Transparent"); + } + + [Fact] + public void HotkeyCaptureBox_ClearButton_VisualTreeCheckExists() + { + XDocument window = LoadWindow(); + string code = File.ReadAllText(Path.Combine(FindRepoRoot(), "AiteBar", "HotkeyCaptureBox.cs")); + + Assert.Contains("IsClickInsideClearButton", code); + Assert.Contains("VisualTreeHelper.GetParent", code); + Assert.Contains("HasAssignedBinding = HotkeyValidationHelper.HasAssignedKey(_binding);", code); + + XElement clearButton = Assert.Single(window.Descendants(PresentationNamespace + "Button"), + element => element.Attribute(XamlNamespace + "Name")?.Value == "ClearButton"); + Assert.Equal("\uF368", clearButton.Attribute("Content")?.Value); + Assert.Equal("32", clearButton.Attribute("Width")?.Value); + Assert.Equal("32", clearButton.Attribute("Height")?.Value); + Assert.Equal("pack://application:,,,/Resources/#FluentSystemIcons-Regular", clearButton.Attribute("FontFamily")?.Value); + Assert.Contains(window.Descendants(PresentationNamespace + "Trigger"), trigger => + trigger.Attribute("Property")?.Value == "HasAssignedBinding" && trigger.Attribute("Value")?.Value == "False"); + } + + [Fact] + public void NavigationHandlers_AreWiredAndLocalizationQueuesSectionRealignment() + { + XDocument window = LoadWindow(); + string code = File.ReadAllText(Path.Combine(FindRepoRoot(), "AiteBar", "AppSettingsWindow.xaml.cs")); + + Assert.Equal("SettingsNavigationList_SelectionChanged", FindNamedElement(window, "SettingsNavigationList").Attribute("SelectionChanged")?.Value); + Assert.Equal("SettingsScrollViewer_ScrollChanged", FindNamedElement(window, "SettingsScrollViewer").Attribute("ScrollChanged")?.Value); + Assert.Equal("Window_Loaded", window.Root?.Attribute("Loaded")?.Value); + Assert.Contains("AppSettingsSectionNavigationHelper.GetTargetOffset", code); + Assert.Contains("AppSettingsSectionNavigationHelper.GetActiveSectionIndex", code); + Assert.Contains("LayoutInformation.GetLayoutSlot(section).Top", code); + Assert.Contains("QueueScrollToSection(selectedSectionIndex);", code); + } + + [Fact] + public void HotkeyInstruction_IsShownOnceAboveRowsAndQuickToolsSortByLocalizedTitles() + { + XDocument window = LoadWindow(); + string code = File.ReadAllText(Path.Combine(FindRepoRoot(), "AiteBar", "AppSettingsWindow.xaml.cs")); + XElement hotkeySection = FindNamedElement(window, "HotkeySettingsSection"); + XElement quickToolsList = FindNamedElement(window, "QuickToolsList"); + + Assert.Single(hotkeySection.Descendants(PresentationNamespace + "TextBlock"), text => + text.Attribute("Text")?.Value == "{local:Loc ResourceKey=HotkeyCapture_RowHint}"); + Assert.DoesNotContain(hotkeySection.Descendants(PresentationNamespace + "Grid"), row => + row.Descendants(PresentationNamespace + "TextBlock").Count(text => + text.Attribute("Text")?.Value == "{local:Loc ResourceKey=HotkeyCapture_RowHint}") > 0); + + Assert.Equal(17, quickToolsList.Elements(PresentationNamespace + "Grid").Count()); + Assert.Contains("private void SortQuickToolRows()", code); + Assert.Contains("StringComparer.Create(LocalizationService.ResolvedCulture, ignoreCase: true)", code); + Assert.Contains("SortQuickToolRows();", code); + + XDocument russianResources = XDocument.Load(Path.Combine(FindRepoRoot(), "AiteBar", "Resources", "Strings.ru.resx")); + XElement searchDescription = Assert.Single(russianResources.Descendants("data"), data => + data.Attribute("name")?.Value == "QuickTool_Search_Description"); + Assert.Equal("Ищет в интернете текст, который сейчас находится в буфере обмена.", searchDescription.Element("value")?.Value); + } + + private static void AssertHandlerCount(XDocument window, string handler, int expectedCount) + { + int actualCount = window.Descendants() + .Count(element => element.Attributes().Any(attribute => string.Equals(attribute.Value, handler, StringComparison.Ordinal))); + Assert.Equal(expectedCount, actualCount); + } + + private static XElement FindNamedElement(XDocument window, string name) => + Assert.Single( + window.Descendants(), + element => string.Equals(element.Attribute(XamlNamespace + "Name")?.Value, name, StringComparison.Ordinal)); + + private static XDocument LoadWindow() => + XDocument.Load(Path.Combine(FindRepoRoot(), "AiteBar", "AppSettingsWindow.xaml")); + + private static XDocument LoadXaml(string fileName) => + XDocument.Load(Path.Combine(FindRepoRoot(), "AiteBar", fileName)); + + private static string FindRepoRoot() + { + string? current = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(current)) + { + if (File.Exists(Path.Combine(current, "AiteBar.sln"))) + { + return current; + } + + current = Directory.GetParent(current)?.FullName; + } + + throw new DirectoryNotFoundException("Repository root with AiteBar.sln was not found."); + } +} diff --git a/AiteBar.Tests/AppSettingsSectionNavigationHelperTests.cs b/AiteBar.Tests/AppSettingsSectionNavigationHelperTests.cs new file mode 100644 index 0000000..2e72030 --- /dev/null +++ b/AiteBar.Tests/AppSettingsSectionNavigationHelperTests.cs @@ -0,0 +1,71 @@ +using AiteBar; + +namespace AiteBar.Tests; + +public sealed class AppSettingsSectionNavigationHelperTests +{ + [Theory] + [InlineData(-50, 800, 0)] + [InlineData(320, 800, 320)] + [InlineData(950, 800, 800)] + [InlineData(double.NaN, 800, 0)] + [InlineData(320, double.NaN, 0)] + public void GetTargetOffset_ClampsToScrollableRange(double sectionTop, double scrollableHeight, double expected) + { + Assert.Equal(expected, AppSettingsSectionNavigationHelper.GetTargetOffset(sectionTop, scrollableHeight)); + } + + [Fact] + public void GetActiveSectionIndex_ReturnsMinusOneForNoSections() + { + Assert.Equal( + -1, + AppSettingsSectionNavigationHelper.GetActiveSectionIndex([], 0, 400, 400)); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(190, 0)] + [InlineData(200, 1)] + [InlineData(610, 2)] + [InlineData(980, 3)] + public void GetActiveSectionIndex_UsesViewportMarker(double offset, int expectedIndex) + { + double[] sectionTops = [0, 224, 624, 1000]; + + int actual = AppSettingsSectionNavigationHelper.GetActiveSectionIndex( + sectionTops, + offset, + viewportHeight: 300, + extentHeight: 1600, + activationInset: 24); + + Assert.Equal(expectedIndex, actual); + } + + [Fact] + public void GetActiveSectionIndex_ChoosesLastSectionAtBottomWhenItsTopCannotReachMarker() + { + double[] sectionTops = [0, 300, 700, 1150]; + + int actual = AppSettingsSectionNavigationHelper.GetActiveSectionIndex( + sectionTops, + verticalOffset: 900, + viewportHeight: 500, + extentHeight: 1400); + + Assert.Equal(3, actual); + } + + [Fact] + public void GetActiveSectionIndex_ShortPageSelectsLastSectionAtBottom() + { + Assert.Equal( + 1, + AppSettingsSectionNavigationHelper.GetActiveSectionIndex( + [0, 120], + verticalOffset: 0, + viewportHeight: 300, + extentHeight: 240)); + } +} diff --git a/AiteBar.Tests/AppSettingsServiceTests.cs b/AiteBar.Tests/AppSettingsServiceTests.cs index e4ab880..78d0a63 100644 --- a/AiteBar.Tests/AppSettingsServiceTests.cs +++ b/AiteBar.Tests/AppSettingsServiceTests.cs @@ -972,6 +972,18 @@ public void CloneAppSettings_CopiesAllProperties() ShowPresetShowDesktop = false, ShowPresetAppsFolder = false, ShowPresetCopilot = false, + ShowPresetTextProcessing = false, + + TextProcessingLeft = 100.0, + TextProcessingTop = 200.0, + TextProcessingWidth = 1280.0, + TextProcessingHeight = 840.0, + TextProcessingWindowState = "Maximized", + TextProcessingLastMode = 1, + TextProcessingSelectedConnectionId = "connection-1", + TextProcessingSelectedModelId = "model-1", + TextProcessingSelectedProviderId = "provider-1", + TextProcessingIsAutoModel = false, ClipboardManagerPersistHistory = false, QuickNoteThemeId = "light", @@ -993,6 +1005,8 @@ public void CloneAppSettings_CopiesAllProperties() ActiveContextId = "context-3", CheckForUpdatesEnabled = false, ShowTaskbarPositionIndicator = false, + TaskbarIndicatorPositionX = 0.75, + TaskbarIndicatorPositionY = 0.25, NextContextHotkey = new HotkeyBinding { Ctrl = true, Alt = false, Shift = true, Win = false, Key = "A" }, PreviousContextHotkey = new HotkeyBinding { Ctrl = false, Alt = true, Shift = false, Win = true, Key = "B" }, @@ -1002,6 +1016,8 @@ public void CloneAppSettings_CopiesAllProperties() ColorPickerHotkey = new HotkeyBinding { Ctrl = false, Alt = true, Shift = true, Win = false, Key = "F" }, TimerStopwatchHotkey = new HotkeyBinding { Ctrl = true, Alt = true, Shift = true, Win = false, Key = "G" }, QRCodeGeneratorHotkey = new HotkeyBinding { Ctrl = false, Alt = false, Shift = false, Win = true, Key = "H" }, + IconConverterHotkey = new HotkeyBinding { Ctrl = true, Alt = false, Shift = true, Win = false, Key = "I" }, + ClipboardManagerHotkey = new HotkeyBinding { Ctrl = true, Alt = true, Shift = false, Win = false, Key = "V" }, Contexts = [ @@ -1082,6 +1098,18 @@ public void CloneAppSettings_CopiesAllProperties() Assert.False(clone.ShowPresetShowDesktop); Assert.False(clone.ShowPresetAppsFolder); Assert.False(clone.ShowPresetCopilot); + Assert.False(clone.ShowPresetTextProcessing); + + Assert.Equal(100.0, clone.TextProcessingLeft); + Assert.Equal(200.0, clone.TextProcessingTop); + Assert.Equal(1280.0, clone.TextProcessingWidth); + Assert.Equal(840.0, clone.TextProcessingHeight); + Assert.Equal("Maximized", clone.TextProcessingWindowState); + Assert.Equal(1, clone.TextProcessingLastMode); + Assert.Equal("connection-1", clone.TextProcessingSelectedConnectionId); + Assert.Equal("model-1", clone.TextProcessingSelectedModelId); + Assert.Equal("provider-1", clone.TextProcessingSelectedProviderId); + Assert.False(clone.TextProcessingIsAutoModel); Assert.False(clone.ClipboardManagerPersistHistory); Assert.Equal("light", clone.QuickNoteThemeId); @@ -1103,6 +1131,8 @@ public void CloneAppSettings_CopiesAllProperties() Assert.Equal("context-3", clone.ActiveContextId); Assert.False(clone.CheckForUpdatesEnabled); Assert.False(clone.ShowTaskbarPositionIndicator); + Assert.Equal(0.75, clone.TaskbarIndicatorPositionX); + Assert.Equal(0.25, clone.TaskbarIndicatorPositionY); // HotkeyBinding deep copies AssertCloneHotkeyBinding(original.NextContextHotkey, clone.NextContextHotkey); @@ -1113,6 +1143,8 @@ public void CloneAppSettings_CopiesAllProperties() AssertCloneHotkeyBinding(original.ColorPickerHotkey, clone.ColorPickerHotkey); AssertCloneHotkeyBinding(original.TimerStopwatchHotkey, clone.TimerStopwatchHotkey); AssertCloneHotkeyBinding(original.QRCodeGeneratorHotkey, clone.QRCodeGeneratorHotkey); + AssertCloneHotkeyBinding(original.IconConverterHotkey, clone.IconConverterHotkey); + AssertCloneHotkeyBinding(original.ClipboardManagerHotkey, clone.ClipboardManagerHotkey); // Contexts deep copy Assert.Equal(2, clone.Contexts.Count); diff --git a/AiteBar.Tests/AppSettingsWindowIntegrationTests.cs b/AiteBar.Tests/AppSettingsWindowIntegrationTests.cs index fac1c4f..c8e7108 100644 --- a/AiteBar.Tests/AppSettingsWindowIntegrationTests.cs +++ b/AiteBar.Tests/AppSettingsWindowIntegrationTests.cs @@ -5,6 +5,41 @@ namespace AiteBar.Tests; public sealed class AppSettingsWindowIntegrationTests { + [Fact] + public void ProgramSettings_IsNormalByDefaultAndOffersAnExplicitSessionPin() + { + string repoRoot = FindRepoRoot(); + string settingsCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml.cs")); + string mainWindowCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "MainWindow.xaml.cs")); + + Assert.Contains("private void BtnKeepOnTop_Click", settingsCode); + Assert.Contains("Topmost = BtnKeepOnTop.IsChecked == true;", settingsCode); + Assert.DoesNotContain("EnsureAlwaysOnTop", settingsCode); + Assert.DoesNotContain("NativeMethods.SetWindowPos", settingsCode); + Assert.Contains("settingsWindow.Show();", mainWindowCode); + Assert.DoesNotContain("new AppSettingsWindow(this).ShowDialog();", mainWindowCode); + Assert.Contains("private AppSettingsWindow? _appSettingsWindow;", mainWindowCode); + Assert.Contains("_appSettingsWindow.Activate();", mainWindowCode); + } + + [Fact] + public void About_UsesTheReusableFinalSettingsSectionInsteadOfASeparateWindow() + { + string repoRoot = FindRepoRoot(); + string settingsXaml = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml")); + string settingsCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml.cs")); + string mainWindowCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "MainWindow.xaml.cs")); + string trayCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "MainWindow.TrayMenuHandler.cs")); + + Assert.Contains("x:Name=\"AboutSettingsSection\"", settingsXaml); + Assert.Contains("public void NavigateToSection(AppSettingsSection section)", settingsCode); + Assert.Contains("ShowAppSettingsWindow(AppSettingsSection section = AppSettingsSection.General)", mainWindowCode); + Assert.Contains("ShowAppSettingsWindow(AppSettingsSection.About)", trayCode); + Assert.DoesNotContain("new AboutWindow", trayCode); + Assert.False(File.Exists(Path.Combine(repoRoot, "AiteBar", "AboutWindow.xaml"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "AiteBar", "AboutWindow.xaml.cs"))); + } + [Fact] public void LanguageSelection_PersistsUiCultureImmediately() { @@ -12,7 +47,7 @@ public void LanguageSelection_PersistsUiCultureImmediately() string settingsCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml.cs")); Assert.Contains("private string _selectedUiCulture = LocalizationService.AutoCulture;", settingsCode); - Assert.Contains("private async void SegLanguage_Click", settingsCode); + Assert.Contains("private async void CmbLanguage_SelectionChanged", settingsCode); Assert.Contains("_selectedUiCulture = LocalizationService.NormalizeCultureName(selectedCulture);", settingsCode); Assert.Contains("_settings.UiCulture = _selectedUiCulture;", settingsCode); Assert.Contains("_mainWindow.GetSettingsService().NormalizeAppState();", settingsCode); @@ -20,7 +55,7 @@ public void LanguageSelection_PersistsUiCultureImmediately() Assert.Contains("LocalizationService.EnsureAppliedCulture();", settingsCode); Assert.Contains("LocalizationService.RefreshLocalizedBindings(this);", settingsCode); Assert.Contains("string language = _selectedUiCulture;", settingsCode); - Assert.Contains("SelectSegmentByTag(language, SegLangAuto, SegLangEn, SegLangDe, SegLangUk, SegLangRu);", settingsCode); + Assert.Contains("SetComboValue(CmbLanguage, language);", settingsCode); Assert.Contains("private void RefreshLocalizedUi()", settingsCode); Assert.Contains("CaptureContextRowDrafts()", settingsCode); Assert.Contains("ApplyContextRowDrafts(drafts);", settingsCode); @@ -28,6 +63,54 @@ public void LanguageSelection_PersistsUiCultureImmediately() Assert.Contains("protected override void OnLocalizationChanged()", settingsCode); } + [Fact] + public void LanguageSelection_HandlesSaveAsyncErrorGracefully() + { + string repoRoot = FindRepoRoot(); + string settingsCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml.cs")); + + Assert.Contains("catch (Exception ex)", settingsCode); + Assert.Contains("Logger.Log(ex);", settingsCode); + Assert.Contains("Settings_SaveFailed", settingsCode); + } + + [Fact] + public void ValidateHotkeyBindings_ChecksReservedHotkeys() + { + string repoRoot = FindRepoRoot(); + string settingsCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml.cs")); + + Assert.Contains("HotkeyValidationHelper.IsReservedHotkey", settingsCode); + Assert.Contains("HotkeyGlobalReservedMessage", settingsCode); + } + + [Fact] + public void NormalizeDiscreteSettings_NormalizesOnLoad() + { + string repoRoot = FindRepoRoot(); + string settingsCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml.cs")); + + Assert.Contains("private void NormalizeDiscreteSettings()", settingsCode); + Assert.Contains("NormalizeDiscreteSettings();", settingsCode); + } + + [Fact] + public void EveryBuiltInWindowUtility_HasASettingsBindingAndExecutionCommand() + { + string repoRoot = FindRepoRoot(); + string settingsCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml.cs")); + string mainWindowCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "MainWindow.xaml.cs")); + + Assert.Contains("HotkeyIconConverter.SetBinding(_settings.IconConverterHotkey);", settingsCode); + Assert.Contains("settings.IconConverterHotkey = iconConverterBinding;", settingsCode); + Assert.Contains("HotkeyClipboardManager.SetBinding(_settings.ClipboardManagerHotkey);", settingsCode); + Assert.Contains("settings.ClipboardManagerHotkey = clipboardManagerBinding;", settingsCode); + Assert.Contains("case HotkeyCommand.IconConverter:", mainWindowCode); + Assert.Contains("LaunchUtilityAsync(\"IconConverter\"", mainWindowCode); + Assert.Contains("case HotkeyCommand.ClipboardManager:", mainWindowCode); + Assert.Contains("LaunchUtilityAsync(\"ClipboardManager\"", mainWindowCode); + } + private static string FindRepoRoot() { string? current = AppContext.BaseDirectory; diff --git a/AiteBar.Tests/ComboBoxPopupChromeTests.cs b/AiteBar.Tests/ComboBoxPopupChromeTests.cs new file mode 100644 index 0000000..3061555 --- /dev/null +++ b/AiteBar.Tests/ComboBoxPopupChromeTests.cs @@ -0,0 +1,75 @@ +using System.IO; +using System.Xml.Linq; + +namespace AiteBar.Tests; + +public sealed class ComboBoxPopupChromeTests +{ + [Theory] + [InlineData(300, 100, true)] + [InlineData(100, 136, false)] + public void IsPopupAbove_UsesActualPopupPosition( + double comboTop, + double popupTop, + bool expected) + { + Assert.Equal(expected, ComboBoxPopupChrome.IsPopupAbove(comboTop, popupTop)); + } + + [Fact] + public void BaseStyle_SwapsJoinedCornersWhenPopupOpensAbove() + { + XDocument resources = XDocument.Load(Path.Combine( + FindRepoRoot(), + "AiteBar", + "FormControlsResources.xaml")); + XNamespace presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace xaml = "http://schemas.microsoft.com/winfx/2006/xaml"; + + XElement style = Assert.Single(resources.Descendants(presentation + "Style"), + element => element.Attribute(xaml + "Key")?.Value == "BaseComboBoxStyle"); + XElement aboveTrigger = Assert.Single(style.Descendants(presentation + "MultiTrigger"), + trigger => trigger.Descendants(presentation + "Condition").Any(condition => + condition.Attribute("Property")?.Value == "local:ComboBoxPopupChrome.OpensAbove")); + + Assert.Contains(aboveTrigger.Descendants(presentation + "Setter"), setter => + setter.Attribute("TargetName")?.Value == "Outline" && + setter.Attribute("Property")?.Value == "CornerRadius" && + setter.Attribute("Value")?.Value == "0,0,4,4"); + Assert.Contains(aboveTrigger.Descendants(presentation + "Setter"), setter => + setter.Attribute("TargetName")?.Value == "DropDownBorder" && + setter.Attribute("Property")?.Value == "CornerRadius" && + setter.Attribute("Value")?.Value == "4,4,0,0"); + Assert.Contains(aboveTrigger.Descendants(presentation + "Setter"), setter => + setter.Attribute("TargetName")?.Value == "DropDownBorder" && + setter.Attribute("Property")?.Value == "BorderThickness" && + setter.Attribute("Value")?.Value == "1,1,1,0"); + } + + [Fact] + public void BaseStyle_UsesPixelScrollingWithoutTrailingPartialRowGap() + { + XDocument resources = XDocument.Load(Path.Combine( + FindRepoRoot(), + "AiteBar", + "FormControlsResources.xaml")); + XNamespace presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace xaml = "http://schemas.microsoft.com/winfx/2006/xaml"; + + XElement style = Assert.Single(resources.Descendants(presentation + "Style"), + element => element.Attribute(xaml + "Key")?.Value == "BaseComboBoxStyle"); + XElement popupScrollViewer = Assert.Single(style.Descendants(presentation + "ScrollViewer"), + element => element.Attribute("CanContentScroll") != null); + + Assert.Equal("False", popupScrollViewer.Attribute("CanContentScroll")?.Value); + } + + private static string FindRepoRoot( + [System.Runtime.CompilerServices.CallerFilePath] string sourceFile = "") + { + return Path.GetFullPath(Path.Combine( + Path.GetDirectoryName(sourceFile) + ?? throw new DirectoryNotFoundException("The test source directory was not found."), + "..")); + } +} diff --git a/AiteBar.Tests/FormControlHeightTests.cs b/AiteBar.Tests/FormControlHeightTests.cs index 212d833..6f41d32 100644 --- a/AiteBar.Tests/FormControlHeightTests.cs +++ b/AiteBar.Tests/FormControlHeightTests.cs @@ -51,7 +51,7 @@ public void TextInputs_UseDedicatedDarkBackgroundAndCompactPadding() Assert.Equal("#2D2D2D", inputBackground.Attribute("Color")?.Value); Assert.Equal("#2D2D2D", inputHoverBackground.Attribute("Color")?.Value); - Assert.Equal("5,0,0,0", placeholderMargin.Value); + Assert.Equal("8,0,0,0", placeholderMargin.Value); XElement textBoxStyle = FindStyle(resources, "BaseTextBoxStyle"); AssertSetter(textBoxStyle, "Background", "{StaticResource FormInputBackground}"); @@ -139,7 +139,6 @@ public void TimerPresetArea_FitsTwo36PixelButtonsAndTheirMargins() [InlineData("SettingsResources.xaml", "SegmentedRadioButtonStyle")] [InlineData("AppSettingsWindow.xaml", "HotkeyModifierButtonStyle")] [InlineData("QRCodeGeneratorWindow.xaml", "CompactComboStyle")] - [InlineData("QRCodeGeneratorWindow.xaml", "CompactTextBoxStyle")] [InlineData("QuickNoteWindow.xaml", "FormatComboStyle")] [InlineData("IconPickerWindow.xaml", "SearchTextBoxStyle")] [InlineData("IconConverterWindow.xaml", "OptionRadioButtonStyle")] @@ -206,7 +205,7 @@ await RunStaAsync(() => Assert.Equal(36d, selectionButton.Height); Assert.Equal(new Thickness(4, 0, 4, 0), textBox.Padding); Assert.Equal( - new Thickness(5, 0, 0, 0), + new Thickness(8, 0, 0, 0), Assert.IsType(Application.Current.FindResource("FormInputPlaceholderMargin"))); Assert.Equal( Color.FromRgb(0x2D, 0x2D, 0x2D), diff --git a/AiteBar.Tests/HotkeyCaptureHelperTests.cs b/AiteBar.Tests/HotkeyCaptureHelperTests.cs new file mode 100644 index 0000000..018ddf4 --- /dev/null +++ b/AiteBar.Tests/HotkeyCaptureHelperTests.cs @@ -0,0 +1,110 @@ +using System.Windows.Input; + +namespace AiteBar.Tests; + +public sealed class HotkeyCaptureHelperTests +{ + [Fact] + public void TryCreateBinding_CapturesCatalogKeyAndModifiersInStableOrder() + { + bool captured = HotkeyCaptureHelper.TryCreateBinding( + Key.P, + ModifierKeys.Control | ModifierKeys.Alt, + out HotkeyBinding binding); + + Assert.True(captured); + Assert.True(binding.Ctrl); + Assert.True(binding.Alt); + Assert.False(binding.Shift); + Assert.Equal("P", binding.Key); + Assert.Equal("Ctrl + Alt + P", HotkeyCaptureHelper.Format(binding, "Not assigned")); + } + + [Theory] + [InlineData(Key.D4, ModifierKeys.Shift, "D4", "Shift + 4")] + [InlineData(Key.OemOpenBrackets, ModifierKeys.Control, "Oem4", "Ctrl + [")] + [InlineData(Key.NumPad7, ModifierKeys.Windows, "NumPad7", "Win + NumPad 7")] + [InlineData(Key.F12, ModifierKeys.Control | ModifierKeys.Shift, "F12", "Ctrl + Shift + F12")] + public void TryCreateBinding_NormalizesSupportedKeyFamilies( + Key key, + ModifierKeys modifiers, + string expectedToken, + string expectedDisplay) + { + Assert.True(HotkeyCaptureHelper.TryCreateBinding(key, modifiers, out HotkeyBinding binding)); + Assert.Equal(expectedToken, binding.Key); + Assert.Equal(expectedDisplay, HotkeyCaptureHelper.Format(binding, "Not assigned")); + } + + [Theory] + [InlineData(Key.LeftCtrl)] + [InlineData(Key.RightCtrl)] + [InlineData(Key.LeftShift)] + [InlineData(Key.RightShift)] + [InlineData(Key.LeftAlt)] + [InlineData(Key.RightAlt)] + [InlineData(Key.LWin)] + [InlineData(Key.RWin)] + [InlineData(Key.Escape)] + [InlineData(Key.Tab)] + [InlineData(Key.Delete)] + [InlineData(Key.Back)] + public void TryCreateBinding_RejectsModifierAndUnsupportedKeys(Key key) + { + Assert.False(HotkeyCaptureHelper.TryCreateBinding(key, ModifierKeys.None, out _)); + } + + [Theory] + [InlineData(ModifierKeys.Control)] + [InlineData(ModifierKeys.Shift)] + [InlineData(ModifierKeys.Windows)] + public void TryCreateBinding_AcceptsSingleModifierWithKey(ModifierKeys modifier) + { + bool captured = HotkeyCaptureHelper.TryCreateBinding( + Key.A, + modifier, + out HotkeyBinding binding); + + Assert.True(captured); + Assert.Equal("A", binding.Key); + } + + [Fact] + public void Clone_DoesNotShareMutableBinding() + { + var source = new HotkeyBinding { Ctrl = true, Key = "A" }; + HotkeyBinding clone = HotkeyCaptureHelper.Clone(source); + + clone.Key = "B"; + + Assert.Equal("A", source.Key); + Assert.Equal("B", clone.Key); + } + + [Fact] + public void Format_UsesNotAssignedTextForEmptyBinding() + { + Assert.Equal("Не назначено", HotkeyCaptureHelper.Format(new HotkeyBinding(), "Не назначено")); + } + + [Theory] + [InlineData(false, false, false, true, "E")] // Win+E + [InlineData(false, false, false, true, "R")] // Win+R + [InlineData(false, false, false, true, "L")] // Win+L + [InlineData(true, true, false, false, "Delete")] // Ctrl+Alt+Del + public void IsReservedHotkey_DetectsSystemCombinations(bool ctrl, bool alt, bool shift, bool win, string key) + { + var binding = new HotkeyBinding { Ctrl = ctrl, Alt = alt, Shift = shift, Win = win, Key = key }; + Assert.True(HotkeyValidationHelper.IsReservedHotkey(binding)); + } + + [Theory] + [InlineData(true, false, false, false, "P")] // Ctrl+P (not reserved) + [InlineData(false, true, false, false, "A")] // Alt+A (not reserved) + [InlineData(true, false, false, false, "E")] // Ctrl+E (not reserved) + public void IsReservedHotkey_AllowsNonReservedCombinations(bool ctrl, bool alt, bool shift, bool win, string key) + { + var binding = new HotkeyBinding { Ctrl = ctrl, Alt = alt, Shift = shift, Win = win, Key = key }; + Assert.False(HotkeyValidationHelper.IsReservedHotkey(binding)); + } +} diff --git a/AiteBar.Tests/HotkeyServiceTests.cs b/AiteBar.Tests/HotkeyServiceTests.cs index 08c1778..763d2bb 100644 --- a/AiteBar.Tests/HotkeyServiceTests.cs +++ b/AiteBar.Tests/HotkeyServiceTests.cs @@ -143,13 +143,17 @@ public void TryGetCommand_MapsProductionIds() Assert.True(service.TryGetCommand(HotkeyService.ShowPanelId, out var showPanel)); Assert.True(service.TryGetCommand(HotkeyService.FileSorterId, out var fileSorter)); + Assert.True(service.TryGetCommand(HotkeyService.IconConverterId, out var iconConverter)); Assert.True(service.TryGetCommand(HotkeyService.TimerStopwatchId, out var timerStopwatch)); Assert.True(service.TryGetCommand(HotkeyService.QRCodeGeneratorId, out var qrCodeGenerator)); + Assert.True(service.TryGetCommand(HotkeyService.ClipboardManagerId, out var clipboardManager)); Assert.False(service.TryGetCommand(123, out _)); Assert.Equal(HotkeyCommand.ShowPanel, showPanel); Assert.Equal(HotkeyCommand.FileSorter, fileSorter); + Assert.Equal(HotkeyCommand.IconConverter, iconConverter); Assert.Equal(HotkeyCommand.TimerStopwatch, timerStopwatch); Assert.Equal(HotkeyCommand.QRCodeGenerator, qrCodeGenerator); + Assert.Equal(HotkeyCommand.ClipboardManager, clipboardManager); } [Fact] @@ -173,10 +177,12 @@ public void CreateDefinitions_UsesExplicitCommandList() HotkeyCommand.PreviousContext, HotkeyCommand.AddButton, HotkeyCommand.FileSorter, + HotkeyCommand.IconConverter, HotkeyCommand.QuickNote, HotkeyCommand.ColorPicker, HotkeyCommand.TimerStopwatch, - HotkeyCommand.QRCodeGenerator + HotkeyCommand.QRCodeGenerator, + HotkeyCommand.ClipboardManager ], definitions.Select(definition => definition.Command)); Assert.Equal("name:AppSettingsWindow_ShowPanel", definitions[0].DisplayName); diff --git a/AiteBar.Tests/IndicatorPositionHelperTests.cs b/AiteBar.Tests/IndicatorPositionHelperTests.cs new file mode 100644 index 0000000..9c6b1f1 --- /dev/null +++ b/AiteBar.Tests/IndicatorPositionHelperTests.cs @@ -0,0 +1,50 @@ +using AiteBar; +using Point = System.Windows.Point; +using Rect = System.Windows.Rect; +using Size = System.Windows.Size; + +namespace AiteBar.Tests; + +public sealed class IndicatorPositionHelperTests +{ + private static readonly Rect Screen = new(-1920, 0, 1920, 1080); + private static readonly Size Indicator = new(35, 35); + + [Fact] + public void FromNormalized_PreservesRelativePositionAt125PercentScale() + { + Point position = IndicatorPositionHelper.FromNormalized(Screen, Indicator, 0.5, 0.25); + + Assert.Equal(-977.5, position.X, 5); + Assert.Equal(261.25, position.Y, 5); + } + + [Theory] + [InlineData(-5000, -5000, -1920, 0)] + [InlineData(5000, 5000, -35, 1045)] + public void Clamp_KeepsEntireIndicatorInsideScreen(double x, double y, double expectedX, double expectedY) + { + Point position = IndicatorPositionHelper.Clamp(Screen, Indicator, new Point(x, y)); + + Assert.Equal(expectedX, position.X); + Assert.Equal(expectedY, position.Y); + } + + [Fact] + public void RoundTrip_ReturnsSameNormalizedCoordinates() + { + Point position = IndicatorPositionHelper.FromNormalized(Screen, Indicator, 0.73, 0.42); + Point normalized = IndicatorPositionHelper.ToNormalized(Screen, Indicator, position); + + Assert.Equal(0.73, normalized.X, 10); + Assert.Equal(0.42, normalized.Y, 10); + } + + [Fact] + public void InvalidCoordinates_AreClampedToVisibleOrigin() + { + Point position = IndicatorPositionHelper.FromNormalized(Screen, Indicator, double.NaN, double.PositiveInfinity); + + Assert.Equal(Screen.TopLeft, position); + } +} diff --git a/AiteBar.Tests/MainWindowIconConverterOrientationTests.cs b/AiteBar.Tests/MainWindowIconConverterOrientationTests.cs index cad4fa9..0fcd827 100644 --- a/AiteBar.Tests/MainWindowIconConverterOrientationTests.cs +++ b/AiteBar.Tests/MainWindowIconConverterOrientationTests.cs @@ -178,6 +178,56 @@ await RunStaAsync(() => }); } + [Fact] + public async Task OrientationRoundTrip_KeepsStablePrimarySizeFromLargestContext() + { + await RunStaAsync(() => + { + EnsureApplicationResources(); + + var window = new MainWindow(); + try + { + ConfigureSettingsForIconConverterOnly(window); + AppSettings settings = window.GetAppSettings(); + settings.Edge = DockEdge.Top; + settings.PanelSizePercent = 50; + settings.Contexts[1].IsEnabled = true; + settings.ActiveContextId = settings.Contexts[0].Id; + settings.Elements = CreateContextElements(settings.Contexts[1].Id, 20); + window.GetSettingsService().Settings = settings; + + window.RefreshPanel(); + LayoutWindow(window); + + var root = Assert.IsAssignableFrom(window.FindName("RootBorder")); + double initialHorizontalWidth = root.MinWidth; + + ApplyEdge(window, settings, DockEdge.Right); + double rightHeight = root.MinHeight; + + ApplyEdge(window, settings, DockEdge.Left); + Assert.Equal(rightHeight, root.MinHeight); + + ApplyEdge(window, settings, DockEdge.Bottom); + Assert.Equal(initialHorizontalWidth, root.MinWidth); + + settings.ActiveContextId = settings.Contexts[1].Id; + window.GetSettingsService().Settings = settings; + window.RefreshPanel(); + LayoutWindow(window); + Assert.Equal(initialHorizontalWidth, root.MinWidth); + + ApplyEdge(window, settings, DockEdge.Top); + Assert.Equal(initialHorizontalWidth, root.MinWidth); + } + finally + { + window.Close(); + } + }); + } + private static void ConfigureSettingsForIconConverterOnly(MainWindow window) { AppSettings settings = window.GetAppSettings(); @@ -197,6 +247,7 @@ private static void ConfigureSettingsForIconConverterOnly(MainWindow window) settings.ShowPresetShowDesktop = false; settings.ShowPresetAppsFolder = false; settings.ShowPresetCopilot = false; + settings.ShowPresetTextProcessing = false; settings.ShowPresetIconConverter = true; window.GetSettingsService().Settings = settings; } @@ -219,6 +270,14 @@ private static List CreateContextElements(string contextId, int c return elements; } + private static void ApplyEdge(MainWindow window, AppSettings settings, DockEdge edge) + { + settings.Edge = edge; + window.GetSettingsService().Settings = settings; + window.UpdateOrientation(reposition: false); + LayoutWindow(window); + } + private static void LayoutWindow(Window window) { window.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); diff --git a/AiteBar.Tests/QuickNoteContractsTests.cs b/AiteBar.Tests/QuickNoteContractsTests.cs new file mode 100644 index 0000000..33d58b7 --- /dev/null +++ b/AiteBar.Tests/QuickNoteContractsTests.cs @@ -0,0 +1,65 @@ +namespace AiteBar.Tests; + +public sealed class QuickNoteContractsTests +{ + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(6)] + public void HeadingTag_RoundTripsSupportedLevels(int level) + { + Assert.True(QuickNoteTags.TryGetHeadingLevel(QuickNoteTags.Heading(level), out int parsed)); + Assert.Equal(level, parsed); + } + + [Theory] + [InlineData("heading:0")] + [InlineData("heading:7")] + [InlineData("heading:one")] + [InlineData("Heading:1")] + [InlineData(null)] + public void HeadingTag_RejectsInvalidValues(string? tag) + { + Assert.False(QuickNoteTags.TryGetHeadingLevel(tag, out _)); + } + + [Fact] + public void LinkAndIndentTags_RoundTripWithoutParsingPayloadContents() + { + const string url = "https://example.com/?value=link:test"; + Assert.Equal(url, QuickNoteTags.GetLink(QuickNoteTags.Link(url))); + Assert.Equal(" ", QuickNoteTags.GetIndent(QuickNoteTags.Indent(" "))); + } + + [Theory] + [InlineData("Left", 0)] + [InlineData("Right", 1)] + [InlineData("Top", 2)] + [InlineData("TopLeft", 3)] + [InlineData("TopRight", 4)] + [InlineData("Bottom", 5)] + [InlineData("BottomLeft", 6)] + [InlineData("BottomRight", 7)] + public void ResizeEdge_ParsesKnownXamlTags(string value, int expected) + { + Assert.True(QuickNoteResizeEdges.TryParse(value, out QuickNoteResizeEdge parsed)); + Assert.Equal((QuickNoteResizeEdge)expected, parsed); + } + + [Theory] + [InlineData("")] + [InlineData("left")] + [InlineData("Unknown")] + [InlineData(null)] + public void ResizeEdge_RejectsUnknownTags(string? value) + { + Assert.False(QuickNoteResizeEdges.TryParse(value, out _)); + } + + [Fact] + public void FontContract_UsesExpectedFamilies() + { + Assert.Equal(QuickNoteFonts.DefaultFamilyName, QuickNoteFonts.Default.Source); + Assert.Equal(QuickNoteFonts.CodeFamilyName, QuickNoteFonts.Code.Source); + } +} diff --git a/AiteBar.Tests/QuickNoteDocumentHelperTests.cs b/AiteBar.Tests/QuickNoteDocumentHelperTests.cs index 22c3c11..07cf71d 100644 --- a/AiteBar.Tests/QuickNoteDocumentHelperTests.cs +++ b/AiteBar.Tests/QuickNoteDocumentHelperTests.cs @@ -90,6 +90,57 @@ public void GetTextPointerAtOffset_ClampsNegativeAndPastEndOffsets() }); } + [Fact] + public void GetTextPointerAtOffset_ReturnsStartOfTextAfterParagraphMovesOutOfList() + { + RunSta(() => + { + var paragraph = new Paragraph(new Run("note")); + var item = new ListItem(paragraph); + var list = new System.Windows.Documents.List(item); + var document = new FlowDocument(list); + item.Blocks.Remove(paragraph); + document.Blocks.InsertBefore(list, paragraph); + document.Blocks.Remove(list); + + TextPointer start = QuickNoteDocumentHelper.GetTextPointerAtOffset(document, 0)!; + + Assert.Equal("note", new TextRange(start, paragraph.ContentEnd).Text); + }); + } + + [Fact] + public void RemapSelection_PreservesSelectedTextWhenListStructureRemovesHiddenLineBreaks() + { + var selection = QuickNoteDocumentHelper.RemapSelection( + "\n\nformatted item\n\n", + "formatted item\n", + 2, + 16); + + Assert.Equal((0, 14), selection); + } + + [Fact] + public void RemapSelection_UsesNearestMatchingTextWhenContentRepeats() + { + var selection = QuickNoteDocumentHelper.RemapSelection( + "\n\nsame\nsame\n\n", + "same\nsame\n", + 7, + 11); + + Assert.Equal((5, 9), selection); + } + + [Theory] + [InlineData("•\tone", "one")] + [InlineData("1.\tone\n2.\ttwo", "one\ntwo")] + public void RemoveVisualListMarkers_RemovesWpfMarkersFromSelectedText(string text, string expected) + { + Assert.Equal(expected, QuickNoteDocumentHelper.RemoveVisualListMarkers(text)); + } + private static void RunSta(Action action) { Exception? exception = null; diff --git a/AiteBar.Tests/QuickNoteFormattingControlsTests.cs b/AiteBar.Tests/QuickNoteFormattingControlsTests.cs new file mode 100644 index 0000000..24d9578 --- /dev/null +++ b/AiteBar.Tests/QuickNoteFormattingControlsTests.cs @@ -0,0 +1,86 @@ +using System.IO; +using System.Xml.Linq; + +namespace AiteBar.Tests; + +public sealed class QuickNoteFormattingControlsTests +{ + private static readonly XNamespace PresentationNamespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + private static readonly XNamespace XamlNamespace = "http://schemas.microsoft.com/winfx/2006/xaml"; + + [Fact] + public void HeadingCombo_ContainsBodyAndAllSixHeadingLevels() + { + XElement combo = FindCombo("CmbHeading"); + + Assert.Equal( + ["0", "1", "2", "3", "4", "5", "6"], + combo.Elements(PresentationNamespace + "ComboBoxItem") + .Select(item => item.Attribute("Tag")?.Value ?? string.Empty) + .ToArray()); + } + + [Fact] + public void ListCombo_ContainsBulletAndNumberedChoices() + { + XElement combo = FindCombo("CmbList"); + + Assert.Equal( + ["bullet", "numbered"], + combo.Elements(PresentationNamespace + "ComboBoxItem") + .Select(item => item.Attribute("Tag")?.Value ?? string.Empty) + .ToArray()); + } + + [Fact] + public void FormattingCombos_ResetAfterEachCommandSoTheSameChoiceCanBeUsedAgain() + { + Assert.Equal("-1", FindCombo("CmbHeading").Attribute("SelectedIndex")?.Value); + Assert.Equal("-1", FindCombo("CmbList").Attribute("SelectedIndex")?.Value); + + string code = File.ReadAllText(Path.Combine( + AppContext.BaseDirectory, + "..", "..", "..", "..", + "AiteBar", + "QuickNoteWindow.xaml.cs")); + Assert.Equal(2, code.Split("ResetFormatCombo(comboBox, -1);").Length - 1); + } + + [Fact] + public void Toolbar_KeepsCompactPrimaryCommandsAndOverflowMenu() + { + XDocument document = LoadDocument(); + XElement toolbar = document + .Descendants(PresentationNamespace + "StackPanel") + .Single(element => element.Elements(PresentationNamespace + "ComboBox") + .Any(combo => (string?)combo.Attribute(XamlNamespace + "Name") == "CmbHeading")); + + Assert.Equal(5, toolbar.Elements(PresentationNamespace + "Button").Count()); + XElement overflow = toolbar.Elements(PresentationNamespace + "Button") + .Single(button => button.Element(PresentationNamespace + "Button.ContextMenu") != null); + string[] handlers = overflow + .Descendants(PresentationNamespace + "MenuItem") + .Select(item => (string?)item.Attribute("Click")) + .Where(click => click != null) + .Cast() + .ToArray(); + + Assert.Equal( + ["BtnUndo_Click", "BtnRedo_Click", "BtnUnderline_Click", "BtnCode_Click", "BtnClearFormatting_Click"], + handlers); + } + + private static XElement FindCombo(string name) + { + XDocument document = LoadDocument(); + return document + .Descendants(PresentationNamespace + "ComboBox") + .Single(element => (string?)element.Attribute(XamlNamespace + "Name") == name); + } + + private static XDocument LoadDocument() + { + string path = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "AiteBar", "QuickNoteWindow.xaml"); + return XDocument.Load(path); + } +} diff --git a/AiteBar.Tests/QuickNoteLayoutHelperTests.cs b/AiteBar.Tests/QuickNoteLayoutHelperTests.cs index 553cca6..4541d05 100644 --- a/AiteBar.Tests/QuickNoteLayoutHelperTests.cs +++ b/AiteBar.Tests/QuickNoteLayoutHelperTests.cs @@ -101,4 +101,52 @@ public void ClampBoundsToWorkArea_EnforcesMinimumSize() Assert.Equal(QuickNoteLayoutHelper.MinWidth, bounds.Width); Assert.Equal(QuickNoteLayoutHelper.MinHeight, bounds.Height); } + + [Fact] + public void SelectWorkArea_UsesSecondaryMonitorContainingSavedBounds() + { + Rectangle primary = new(0, 0, 1920, 1080); + Rectangle secondary = new(1920, 0, 2560, 1440); + + Rectangle selected = QuickNoteLayoutHelper.SelectWorkArea( + [primary, secondary], + left: 2300, + top: 200, + width: 580, + height: 430); + + Assert.Equal(secondary, selected); + } + + [Fact] + public void SelectWorkArea_SupportsNegativeMonitorCoordinates() + { + Rectangle primary = new(0, 0, 1920, 1080); + Rectangle leftMonitor = new(-1920, 0, 1920, 1080); + + Rectangle selected = QuickNoteLayoutHelper.SelectWorkArea( + [primary, leftMonitor], + left: -1500, + top: 100, + width: 580, + height: 430); + + Assert.Equal(leftMonitor, selected); + } + + [Fact] + public void SelectWorkArea_UsesNearestMonitorWhenSavedMonitorWasRemoved() + { + Rectangle primary = new(0, 0, 1920, 1080); + Rectangle rightMonitor = new(1920, 0, 1920, 1080); + + Rectangle selected = QuickNoteLayoutHelper.SelectWorkArea( + [primary, rightMonitor], + left: 5000, + top: 100, + width: 580, + height: 430); + + Assert.Equal(rightMonitor, selected); + } } diff --git a/AiteBar.Tests/QuickNoteMarkdownTests.cs b/AiteBar.Tests/QuickNoteMarkdownTests.cs index 417859a..9a75aff 100644 --- a/AiteBar.Tests/QuickNoteMarkdownTests.cs +++ b/AiteBar.Tests/QuickNoteMarkdownTests.cs @@ -117,7 +117,20 @@ public void GetClearLineMarkerRangeEdit_ReplacesSelectedLinesAsSingleSegment() Assert.Equal(5, edit.StartOffset); Assert.Equal("- one\n- two".Length, edit.RemoveLength); Assert.Equal("one\ntwo", edit.InsertText); - Assert.True(edit.CaretOffset >= edit.StartOffset); + Assert.Equal(5, edit.CaretOffset); + Assert.Equal("one\ntwo".Length, edit.SelectionLength); + } + + [Fact] + public void GetClearLineMarkerRangeEdit_PreservesPartialSelectionAfterRemovingMarkers() + { + QuickNoteRangeEdit edit = QuickNoteMarkdown.GetClearLineMarkerRangeEdit("head\n- one two\ntail", 7, 10); + + Assert.Equal(5, edit.StartOffset); + Assert.Equal("- one two".Length, edit.RemoveLength); + Assert.Equal("one two", edit.InsertText); + Assert.Equal(5, edit.CaretOffset); + Assert.Equal(3, edit.SelectionLength); } [Fact] @@ -170,6 +183,27 @@ public void LoadMarkdown_RendersSupportedInlineFormatting() Assert.Equal("plain **bold** *italic* `code`", markdown); } + [Theory] + [InlineData("***both***")] + [InlineData("~~under-strike~~")] + [InlineData("**bold `code`**")] + [InlineData("[**bold link**](https://example.com)")] + [InlineData("***bold italic `code`***")] + [InlineData("~~`code strike`~~")] + [InlineData("~~`code strike underline`~~")] + [InlineData("[~~`formatted code link`~~](https://example.com)")] + public void LoadMarkdown_PreservesNestedFormattingRoundTrip(string source) + { + string markdown = RunSta(() => + { + var document = new FlowDocument(); + QuickNoteMarkdown.LoadMarkdown(document, source); + return QuickNoteMarkdown.ToMarkdown(document); + }); + + Assert.Equal(source, markdown); + } + [Fact] public void LoadMarkdown_RendersAndSavesHeadings() { @@ -418,6 +452,24 @@ public void LoadMarkdown_DoesNotTreatEscapedMarkersAsFormatting() Assert.Equal("literal **not bold**", visibleText); } + [Theory] + [InlineData("https://example.com", 0, true)] + [InlineData("file:///C:/Windows/System32/calc.exe", 0, false)] + [InlineData("javascript:alert(1)", 0, false)] + [InlineData("mailto:user@example.com", 1, true)] + [InlineData("mailto:user@example.com\"&calc.exe", 1, false)] + [InlineData("tel:+380 (67) 123-45-67", 2, true)] + [InlineData("tel:../../calc.exe|cmd", 2, false)] + public void IsSafeLinkForOpen_AllowsOnlyExpectedSchemePayloads( + string link, + int type, + bool expected) + { + Assert.Equal( + expected, + QuickNoteMarkdown.IsSafeLinkForOpen(link, (QuickNoteMarkdown.LinkType)type)); + } + [Fact] public void ToMarkdown_PreservesMultipleLines() { diff --git a/AiteBar.Tests/QuickNoteServiceTests.cs b/AiteBar.Tests/QuickNoteServiceTests.cs index fde1613..df0d6c6 100644 --- a/AiteBar.Tests/QuickNoteServiceTests.cs +++ b/AiteBar.Tests/QuickNoteServiceTests.cs @@ -54,6 +54,15 @@ public async Task ReadMarkdownAsync_WhenNoFile_ReturnsEmpty() Assert.Equal(string.Empty, content); } + [Fact] + public async Task HasExternalChanges_WhenFileIsCreatedAfterMissingBaseline_ReturnsTrue() + { + await _service.ReadMarkdownAsync(); + await File.WriteAllTextAsync(_service.NotePath, "external"); + + Assert.True(_service.HasExternalChanges()); + } + [Fact] public async Task HasExternalChanges_AfterLoad_ReturnsFalse() { @@ -79,6 +88,19 @@ public async Task ReadMarkdownAsync_LoadsExistingFileAndTracksExternalChanges() Assert.True(_service.HasExternalChanges()); } + [Fact] + public async Task HasExternalChanges_DetectsSameLengthContentWithRestoredTimestamp() + { + await File.WriteAllTextAsync(_service.NotePath, "first"); + await _service.ReadMarkdownAsync(); + DateTime baselineTimestamp = File.GetLastWriteTimeUtc(_service.NotePath); + + await File.WriteAllTextAsync(_service.NotePath, "other"); + File.SetLastWriteTimeUtc(_service.NotePath, baselineTimestamp); + + Assert.True(_service.HasExternalChanges()); + } + [Fact] public async Task SaveAsync_WritesMarkdownAndClearsExternalChangeState() { @@ -130,6 +152,54 @@ public async Task SaveConflictCopyAsync_WritesConflictFileNextToNoteWithoutChang Assert.Equal(conflictPath, _service.LastConflictCopyPath); } + [Fact] + public async Task SaveConflictCopyAsync_CreatesUniqueFilesForImmediateConflicts() + { + string[] paths = await RunStaAsync(async () => + { + var first = new FlowDocument(new Paragraph(new Run("first"))); + var second = new FlowDocument(new Paragraph(new Run("second"))); + return new[] + { + await _service.SaveConflictCopyAsync(first), + await _service.SaveConflictCopyAsync(second) + }; + }); + + Assert.NotEqual(paths[0], paths[1]); + Assert.Equal("first", await File.ReadAllTextAsync(paths[0])); + Assert.Equal("second", await File.ReadAllTextAsync(paths[1])); + } + + [Fact] + public async Task ReadMarkdownAsync_RestoresLatestConflictCopyAfterServiceRestart() + { + string older = Path.Combine(_tempDir, "QuickNote.conflict-older.md"); + string latest = Path.Combine(_tempDir, "QuickNote.conflict-latest.md"); + await File.WriteAllTextAsync(older, "older"); + await File.WriteAllTextAsync(latest, "latest"); + File.SetLastWriteTimeUtc(older, DateTime.UtcNow.AddMinutes(-1)); + File.SetLastWriteTimeUtc(latest, DateTime.UtcNow); + var restarted = new QuickNoteService(_service.NotePath); + + await restarted.ReadMarkdownAsync(); + + Assert.Equal(latest, restarted.LastConflictCopyPath); + } + + [Fact] + public async Task SaveAsync_DoesNotLeaveTemporaryFiles() + { + await RunStaAsync(async () => + { + var document = new FlowDocument(new Paragraph(new Run("atomic"))); + await _service.SaveAsync(document); + }); + + Assert.Equal("atomic", await File.ReadAllTextAsync(_service.NotePath)); + Assert.Empty(Directory.GetFiles(_tempDir, "*.tmp")); + } + [Fact] public async Task OpenConflictCopy_OpensLastConflictCopy() { diff --git a/AiteBar.Tests/QuickNoteWindowCloseTests.cs b/AiteBar.Tests/QuickNoteWindowCloseTests.cs index dea0f01..128929b 100644 --- a/AiteBar.Tests/QuickNoteWindowCloseTests.cs +++ b/AiteBar.Tests/QuickNoteWindowCloseTests.cs @@ -13,7 +13,16 @@ namespace AiteBar.Tests; public sealed class QuickNoteWindowCloseTests { [Fact] - public async Task Close_WaitsForActiveAndForcedSavesBeforeDisposingWindow() + public void ForcedSaveWaitTimeout_IsBounded() + { + Assert.InRange( + QuickNoteWindow.ForcedSaveWaitTimeout, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(15)); + } + + [Fact] + public async Task Close_WaitsForActiveSaveWithoutWritingUnchangedDocumentAgain() { await RunStaAsync(async () => { @@ -45,15 +54,10 @@ await RunStaAsync(async () => persistence.CompleteSave(0); await firstSave; - await persistence.WaitForSaveCountAsync(2); - - Assert.True(window.IsVisible); - Assert.False(closed.Task.IsCompleted); - - persistence.CompleteSave(1); await closed.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.False(window.IsVisible); + Assert.Equal(1, persistence.SaveCount); } finally { @@ -156,6 +160,7 @@ private sealed class DelayedQuickNotePersistence : IQuickNotePersistence private readonly List _saves = []; private readonly List<(int Count, TaskCompletionSource Completion)> _saveCountWaiters = []; + public int SaveCount => _saves.Count; public string? LastConflictCopyPath => null; public bool HasExternalChanges() => false; public void Load(FlowDocument document) => document.Blocks.Clear(); diff --git a/AiteBar.Tests/QuickNoteWindowFormattingTests.cs b/AiteBar.Tests/QuickNoteWindowFormattingTests.cs new file mode 100644 index 0000000..75270ce --- /dev/null +++ b/AiteBar.Tests/QuickNoteWindowFormattingTests.cs @@ -0,0 +1,250 @@ +using System.IO; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Documents; + +namespace AiteBar.Tests; + +[Collection("WpfTestCollection")] +public sealed class QuickNoteWindowFormattingTests +{ + [Fact] + public void ClearSelectedFormatting_UnwrapsListAndLinkInOneDocumentChange() + { + RunSta(() => + { + string tempRoot = Path.Combine(Path.GetTempPath(), "AiteBarTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + var settingsService = new AppSettingsService( + Path.Combine(tempRoot, "buttons.json"), + Path.Combine(tempRoot, "settings.json")); + using var window = new QuickNoteWindow(new NoOpQuickNotePersistence(), settingsService); + var formattedRun = new Run("formatted item"); + var hyperlink = new Hyperlink(new Bold(formattedRun)) + { + NavigateUri = new Uri("https://example.com") + }; + var list = new System.Windows.Documents.List(); + list.ListItems.Add(new ListItem(new Paragraph(hyperlink))); + window.TxtNote.Document.Blocks.Clear(); + window.TxtNote.Document.Blocks.Add(list); + window.TxtNote.Selection.Select( + formattedRun.ContentStart, + formattedRun.ContentEnd); + Assert.Equal("formatted item", QuickNoteDocumentHelper.RemoveVisualListMarkers(window.TxtNote.Selection.Text)); + + int textChangedCount = 0; + window.TxtNote.TextChanged += (_, _) => textChangedCount++; + + window.ClearSelectedFormatting(); + + var paragraph = Assert.IsType(window.TxtNote.Document.Blocks.FirstBlock); + Assert.Empty(paragraph.Inlines.OfType()); + Assert.Equal("formatted item", window.TxtNote.Selection.Text.Trim()); + Assert.Equal("formatted item", new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); + Assert.Equal(FontWeights.Normal, window.TxtNote.Selection.GetPropertyValue(TextElement.FontWeightProperty)); + Assert.Equal("formatted item", QuickNoteMarkdown.ToMarkdown(window.TxtNote.Document)); + Assert.Equal(1, textChangedCount); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + }); + } + + [Fact] + public void ClearSelectedFormatting_PreservesSelectionAcrossMultipleListItems() + { + RunSta(() => + { + string tempRoot = Path.Combine(Path.GetTempPath(), "AiteBarTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + var settingsService = new AppSettingsService( + Path.Combine(tempRoot, "buttons.json"), + Path.Combine(tempRoot, "settings.json")); + using var window = new QuickNoteWindow(new NoOpQuickNotePersistence(), settingsService); + var first = new Run("first"); + var second = new Run("second"); + var list = new System.Windows.Documents.List(); + list.ListItems.Add(new ListItem(new Paragraph(first))); + list.ListItems.Add(new ListItem(new Paragraph(second))); + window.TxtNote.Document.Blocks.Clear(); + window.TxtNote.Document.Blocks.Add(list); + window.TxtNote.Selection.Select(first.ContentStart, second.ContentEnd); + Assert.Equal( + "first\nsecond", + QuickNoteDocumentHelper.RemoveVisualListMarkers(window.TxtNote.Selection.Text).Trim()); + + window.ClearSelectedFormatting(); + + Assert.Equal(2, window.TxtNote.Document.Blocks.OfType().Count()); + Assert.Equal( + "first\nsecond", + QuickNoteDocumentHelper.NormalizeLineEndings(window.TxtNote.Selection.Text).Trim()); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + }); + } + + [Fact] + public void ClearSelectedFormatting_PreservesUnselectedHyperlinkFragments() + { + RunSta(() => + { + string tempRoot = Path.Combine(Path.GetTempPath(), "AiteBarTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + var settingsService = new AppSettingsService( + Path.Combine(tempRoot, "buttons.json"), + Path.Combine(tempRoot, "settings.json")); + using var window = new QuickNoteWindow(new NoOpQuickNotePersistence(), settingsService); + var run = new Run("prefix selected suffix"); + var hyperlink = new Hyperlink(run) + { + NavigateUri = new Uri("https://example.com"), + Tag = "link:https://example.com" + }; + var paragraph = new Paragraph(hyperlink); + window.TxtNote.Document.Blocks.Clear(); + window.TxtNote.Document.Blocks.Add(paragraph); + window.TxtNote.Selection.Select( + run.ContentStart.GetPositionAtOffset("prefix ".Length)!, + run.ContentStart.GetPositionAtOffset("prefix selected".Length)!); + + window.ClearSelectedFormatting(); + + Hyperlink[] links = paragraph.Inlines.OfType().ToArray(); + Assert.Equal(2, links.Length); + Assert.Equal("prefix ", new TextRange(links[0].ContentStart, links[0].ContentEnd).Text); + Assert.Equal(" suffix", new TextRange(links[1].ContentStart, links[1].ContentEnd).Text); + Assert.Equal("selected", window.TxtNote.Selection.Text); + Assert.Equal( + "prefix selected suffix", + new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + }); + } + + [Fact] + public void ClearSelectedFormatting_PreservesNestedFormattingInLinkedFragments() + { + RunSta(() => + { + string tempRoot = Path.Combine(Path.GetTempPath(), "AiteBarTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + var settingsService = new AppSettingsService( + Path.Combine(tempRoot, "buttons.json"), + Path.Combine(tempRoot, "settings.json")); + using var window = new QuickNoteWindow(new NoOpQuickNotePersistence(), settingsService); + var prefix = new Run("prefix "); + var selected = new Run("selected"); + var suffix = new Run(" suffix"); + var strike = new Span(suffix) { TextDecorations = TextDecorations.Strikethrough }; + var hyperlink = new Hyperlink + { + NavigateUri = new Uri("https://example.com"), + Tag = "link:https://example.com" + }; + hyperlink.Inlines.Add(new Bold(prefix)); + hyperlink.Inlines.Add(new Italic(selected)); + hyperlink.Inlines.Add(strike); + var paragraph = new Paragraph(hyperlink); + window.TxtNote.Document.Blocks.Clear(); + window.TxtNote.Document.Blocks.Add(paragraph); + window.TxtNote.Selection.Select(selected.ContentStart, selected.ContentEnd); + + window.ClearSelectedFormatting(); + + Hyperlink[] links = paragraph.Inlines.OfType().ToArray(); + Assert.Equal(2, links.Length); + Assert.IsType(links[0].Inlines.FirstInline); + var suffixSpan = Assert.IsType(links[1].Inlines.FirstInline); + Assert.Contains( + suffixSpan.TextDecorations, + decoration => decoration.Location == TextDecorationLocation.Strikethrough); + Assert.Equal(FontStyles.Normal, window.TxtNote.Selection.GetPropertyValue(TextElement.FontStyleProperty)); + Assert.Equal("prefix selected suffix", new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); + Assert.Equal( + "[**prefix **](https://example.com)selected[~~ suffix~~](https://example.com)", + QuickNoteMarkdown.ToMarkdown(window.TxtNote.Document)); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + }); + } + + [Fact] + public void LinkDialog_PreservesSelectedWhitespaceInLinkText() + { + Assert.Equal( + " selected text ", + QuickNoteLinkDialog.PreserveLinkText(" selected text ")); + } + + private static void RunSta(Action action) + { + Exception? exception = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + exception = ex; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception != null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } + + private sealed class NoOpQuickNotePersistence : IQuickNotePersistence + { + public string? LastConflictCopyPath => null; + public bool HasExternalChanges() => false; + public void Load(FlowDocument document) + { + } + + public Task SaveAsync(FlowDocument document) => Task.CompletedTask; + public Task SaveConflictCopyAsync(FlowDocument document) => Task.FromResult(string.Empty); + public void OpenInEditor() + { + } + + public void OpenConflictCopy() + { + } + } +} diff --git a/AiteBar.Tests/RuntimeLocalizationWindowSourceTests.cs b/AiteBar.Tests/RuntimeLocalizationWindowSourceTests.cs index 05a8075..2e69563 100644 --- a/AiteBar.Tests/RuntimeLocalizationWindowSourceTests.cs +++ b/AiteBar.Tests/RuntimeLocalizationWindowSourceTests.cs @@ -12,7 +12,8 @@ public void AppSettingsWindow_RebuildsDynamicLocalizedUiOnCultureChange() Assert.Contains("private void RefreshLocalizedUi()", code); Assert.Contains("ReloadLocalizedChoiceLists();", code); - Assert.Contains("LoadKeyList();", code); + Assert.Contains("LoadLanguageList();", code); + Assert.Contains("captureBox.RefreshDisplay();", code); Assert.Contains("RefreshContextRowTooltips();", code); Assert.Contains("protected override void OnLocalizationChanged()", code); } @@ -54,7 +55,6 @@ public void UtilityWindows_ReapplyRuntimeLocalizedStateOnCultureChange() string iconPickerCode = ReadCode("AiteBar", "IconPickerWindow.xaml.cs"); string quickNoteCode = ReadCode("AiteBar", "QuickNoteWindow.xaml.cs"); string rotationCode = ReadCode("AiteBar", "RotationProfileSelectionWindow.xaml.cs"); - string aboutCode = ReadCode("AiteBar", "AboutWindow.xaml.cs"); string dialogCode = ReadCode("AiteBar", "DarkDialog.xaml.cs"); string promptCode = ReadCode("AiteBar", "TextPromptDialog.xaml.cs"); string screenPickerCode = ReadCode("AiteBar", "ScreenColorPickerWindow.cs"); @@ -80,9 +80,6 @@ public void UtilityWindows_ReapplyRuntimeLocalizedStateOnCultureChange() Assert.Contains("RenderProfiles();", rotationCode); Assert.Contains("protected override void OnLocalizationChanged()", rotationCode); - Assert.Contains("UpdateVersionText();", aboutCode); - Assert.Contains("protected override void OnLocalizationChanged()", aboutCode); - Assert.Contains("private readonly bool _isConfirmDialog;", dialogCode); Assert.Contains("protected override void OnLocalizationChanged()", dialogCode); @@ -106,7 +103,6 @@ public void RuntimeLocalizedWindowFiles_DeclareRefreshHooks() "AiteBar\\TimerStopwatchWindow.xaml.cs", "AiteBar\\QuickNoteWindow.xaml.cs", "AiteBar\\RotationProfileSelectionWindow.xaml.cs", - "AiteBar\\AboutWindow.xaml.cs", "AiteBar\\DarkDialog.xaml.cs", "AiteBar\\TextPromptDialog.xaml.cs" ]; diff --git a/AiteBar.Tests/SupportLinksTests.cs b/AiteBar.Tests/SupportLinksTests.cs index e8fe412..2c0d6f2 100644 --- a/AiteBar.Tests/SupportLinksTests.cs +++ b/AiteBar.Tests/SupportLinksTests.cs @@ -6,15 +6,15 @@ namespace AiteBar.Tests; public sealed class SupportLinksTests { [Fact] - public void TrayAndAboutWindow_UseProjectSupportUrl() + public void TrayAndAboutSettings_UseProjectSupportUrl() { string repoRoot = FindRepoRoot(); string mainWindowCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "MainWindow.xaml.cs")); - string aboutCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AboutWindow.xaml.cs")); + string settingsCode = File.ReadAllText(Path.Combine(repoRoot, "AiteBar", "AppSettingsWindow.xaml.cs")); Assert.Contains("private const string DonatePageUrl = \"https://codebdbd.github.io/\";", mainWindowCode); - Assert.Contains("private const string SupportUrl = \"https://codebdbd.github.io/\";", aboutCode); - Assert.Contains("OpenTarget(SupportUrl);", aboutCode); + Assert.Contains("private const string SupportUrl = \"https://codebdbd.github.io/\";", settingsCode); + Assert.Contains("OpenAboutTarget(SupportUrl);", settingsCode); } private static string FindRepoRoot() diff --git a/AiteBar.Tests/TextDiffTests.cs b/AiteBar.Tests/TextDiffTests.cs new file mode 100644 index 0000000..96d6644 --- /dev/null +++ b/AiteBar.Tests/TextDiffTests.cs @@ -0,0 +1,36 @@ +namespace AiteBar.Tests; + +public sealed class TextDiffTests +{ + [Fact] + public void Create_MarksChangedWord() + { + IReadOnlyList segments = TextDiff.Create( + "Это ошбка в тексте.", + "Это ошибка в тексте."); + + Assert.Contains(segments, segment => + segment.Kind == TextDiffKind.Removed && segment.Text.Contains("ошбка", StringComparison.Ordinal)); + Assert.Contains(segments, segment => + segment.Kind == TextDiffKind.Added && segment.Text.Contains("ошибка", StringComparison.Ordinal)); + Assert.Equal( + "Это ошибка в тексте.", + string.Concat(segments.Where(segment => segment.Kind != TextDiffKind.Removed).Select(segment => segment.Text))); + } + + [Fact] + public void Create_PreservesOriginalAndChangedText() + { + const string original = "Первая строка.\nВторая строка."; + const string changed = "Первая новая строка.\nВторая строка!"; + + IReadOnlyList segments = TextDiff.Create(original, changed); + + Assert.Equal( + original, + string.Concat(segments.Where(segment => segment.Kind != TextDiffKind.Added).Select(segment => segment.Text))); + Assert.Equal( + changed, + string.Concat(segments.Where(segment => segment.Kind != TextDiffKind.Removed).Select(segment => segment.Text))); + } +} diff --git a/AiteBar.Tests/TextProcessingEditorBehaviorTests.cs b/AiteBar.Tests/TextProcessingEditorBehaviorTests.cs new file mode 100644 index 0000000..e5322d3 --- /dev/null +++ b/AiteBar.Tests/TextProcessingEditorBehaviorTests.cs @@ -0,0 +1,32 @@ +using AiteBar; + +namespace AiteBar.Tests; + +public sealed class TextProcessingEditorBehaviorTests +{ + [Fact] + public void InsertAtSelection_InsertsAtCaretWithoutReplacingEditor() + { + (string text, int caretIndex) = TextProcessingWindow.InsertAtSelection( + "before after", + 7, + 0, + "new "); + + Assert.Equal("before new after", text); + Assert.Equal(11, caretIndex); + } + + [Fact] + public void InsertAtSelection_ReplacesOnlySelectedText() + { + (string text, int caretIndex) = TextProcessingWindow.InsertAtSelection( + "before old after", + 7, + 3, + "new"); + + Assert.Equal("before new after", text); + Assert.Equal(10, caretIndex); + } +} diff --git a/AiteBar.Tests/TextProcessingModelEligibilityTests.cs b/AiteBar.Tests/TextProcessingModelEligibilityTests.cs new file mode 100644 index 0000000..e8a3ebf --- /dev/null +++ b/AiteBar.Tests/TextProcessingModelEligibilityTests.cs @@ -0,0 +1,107 @@ +namespace AiteBar.Tests; + +public sealed class TextProcessingModelEligibilityTests +{ + [Theory] + [InlineData("openai/whisper-large-v3", "Whisper Large V3")] + [InlineData("provider/prompt-guard", "Prompt Guard")] + [InlineData("provider/text-embedding-3", "Text Embedding 3")] + [InlineData("provider/safety-gpt", "Safety GPT")] + [InlineData("gemini-2.5-flash-image", "Nano Banana")] + [InlineData("gemini-3-pro-image-preview", "Gemini 3 Pro Image Preview")] + [InlineData("imagen-3.0-generate-002", "Imagen 3")] + [InlineData("veo-3.0-generate-preview", "Veo 3")] + [InlineData("provider/generate-image", "Image Generation")] + [InlineData("provider/generate-video", "Video Generation")] + [InlineData("\u200B", "\u200B")] + [InlineData(" ", " ")] + public void IsEligibleModel_RejectsModelsNotIntendedForWriting(string modelId, string displayName) + { + var model = new AiModelDescriptor( + "provider", + modelId, + displayName, + AiCapabilities.Text, + 32_768, + AiCostStatus.VerifiedFree); + + Assert.False(TextProcessingWindow.IsEligibleModel(model)); + } + + [Fact] + public void IsEligibleModel_AcceptsFreeTextGenerationModel() + { + var model = new AiModelDescriptor( + "provider", + "gpt-4.1-mini", + "GPT-4.1 Mini", + AiCapabilities.Text, + 128_000, + AiCostStatus.VerifiedFree); + + Assert.True(TextProcessingWindow.IsEligibleModel(model)); + } + + [Fact] + public void IsEligibleModel_AcceptsMultimodalModelThatReturnsText() + { + var model = new AiModelDescriptor( + "provider", + "gemini-2.5-flash", + "Gemini 2.5 Flash Vision", + AiCapabilities.Text | AiCapabilities.Vision, + 128_000, + AiCostStatus.VerifiedFree); + + Assert.True(TextProcessingWindow.IsEligibleModel(model)); + } + + [Fact] + public void BuildLogicalModelItems_CollapsesRepeatedConnectionsForSameProviderModel() + { + AiModelDescriptor[] routes = + [ + Model("groq", "llama-3.3-70b", "Llama 3.3 70B", 32_000), + Model("groq", "llama-3.3-70b", "Llama 3.3 70B", 128_000), + Model("cerebras", "llama-3.3-70b", "Llama 3.3 70B", 64_000) + ]; + + IReadOnlyList items = TextProcessingWindow.BuildLogicalModelItems(routes); + + Assert.Equal(2, items.Count); + ModelItem groq = Assert.Single(items, item => item.ProviderId == "groq"); + Assert.Equal("llama-3.3-70b", groq.ModelId); + Assert.Equal(128_000, groq.ContextLength); + Assert.Contains("Groq", groq.FullDisplay, StringComparison.Ordinal); + Assert.DoesNotContain(items, item => item.FullDisplay.Contains("ключ", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void BuildLogicalModelItems_ListsPreferredLogicalModelFirstWithoutDuplicatingIt() + { + AiModelDescriptor preferred = Model("groq", "z-model", "Z Model", 32_000); + AiModelDescriptor other = Model("groq", "a-model", "A Model", 32_000); + var preferredIdentities = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "groq\nz-model" + }; + + IReadOnlyList items = TextProcessingWindow.BuildLogicalModelItems( + [preferred, preferred, other], + preferredIdentities); + + Assert.Equal(["z-model", "a-model"], items.Select(item => item.ModelId)); + } + + private static AiModelDescriptor Model( + string providerId, + string modelId, + string displayName, + int? contextLength) => new( + providerId, + modelId, + displayName, + AiCapabilities.Text, + contextLength, + AiCostStatus.VerifiedFree); +} diff --git a/AiteBar.Tests/TextProcessingProtectionTests.cs b/AiteBar.Tests/TextProcessingProtectionTests.cs new file mode 100644 index 0000000..07cc80d --- /dev/null +++ b/AiteBar.Tests/TextProcessingProtectionTests.cs @@ -0,0 +1,73 @@ +using System.IO; + +namespace AiteBar.Tests; + +public sealed class TextProcessingProtectionTests +{ + private readonly TextProcessingService _service = new(); + + [Fact] + public void ProtectAndRestoreTechnicalFragments_RoundTripsSupportedFragments() + { + const string source = + "Открой https://example.com/a?q=1, напиши user@example.com и запусти `dotnet test`.\n" + + "Файл C:\\Temp\\report.txt, версия v1.2.3 и ${HOME}. тег.\n" + + "UUID 43b85022-3b6e-4681-92be-a5c003a30b77.\n```csharp\nvar x = 1;\n```"; + + ProtectedText protectedText = _service.ProtectTechnicalFragments(source); + + Assert.DoesNotContain("https://example.com", protectedText.Text, StringComparison.Ordinal); + Assert.DoesNotContain("user@example.com", protectedText.Text, StringComparison.Ordinal); + Assert.DoesNotContain("dotnet test", protectedText.Text, StringComparison.Ordinal); + Assert.DoesNotContain("v1.2.3", protectedText.Text, StringComparison.Ordinal); + Assert.DoesNotContain("43b85022-3b6e-4681-92be-a5c003a30b77", protectedText.Text, StringComparison.Ordinal); + Assert.Equal(source, TextProcessingService.RestoreTechnicalFragments(protectedText.Text, protectedText)); + } + + [Fact] + public void RestoreTechnicalFragments_RestoresMarkersInsideChangedNaturalText() + { + ProtectedText protectedText = _service.ProtectTechnicalFragments("ошбка https://example.com"); + string changed = protectedText.Text.Replace("ошбка", "ошибка", StringComparison.Ordinal); + + Assert.Equal( + "ошибка https://example.com", + TextProcessingService.RestoreTechnicalFragments(changed, protectedText)); + } + + [Fact] + public void ProtectTechnicalFragments_DoesNotChangePlainText() + { + ProtectedText protectedText = _service.ProtectTechnicalFragments("Обычный текст без технических фрагментов."); + + Assert.Empty(protectedText.Fragments); + Assert.Equal("Обычный текст без технических фрагментов.", protectedText.Text); + } + + [Fact] + public void ProtectAndRestoreTechnicalFragments_RoundTripsCliFlagsAndDataUris() + { + const string source = + "Запусти apt-get --no-install-recommends --config=/tmp/app.conf и сохрани " + + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB."; + + ProtectedText protectedText = _service.ProtectTechnicalFragments(source); + + Assert.DoesNotContain("--no-install-recommends", protectedText.Text, StringComparison.Ordinal); + Assert.DoesNotContain("--config=/tmp/app.conf", protectedText.Text, StringComparison.Ordinal); + Assert.DoesNotContain("data:image/png;base64", protectedText.Text, StringComparison.Ordinal); + Assert.Equal(source, TextProcessingService.RestoreTechnicalFragments(protectedText.Text, protectedText)); + } + + [Fact] + public void RestoreTechnicalFragments_RejectsMissingMarkerInFinalResponse() + { + ProtectedText protectedText = _service.ProtectTechnicalFragments("Открой https://example.com"); + + Assert.Throws(() => + TextProcessingService.RestoreTechnicalFragments( + "Открой", + protectedText, + requireAllMarkers: true)); + } +} diff --git a/AiteBar.Tests/TextProcessingServiceTests.cs b/AiteBar.Tests/TextProcessingServiceTests.cs new file mode 100644 index 0000000..cef6453 --- /dev/null +++ b/AiteBar.Tests/TextProcessingServiceTests.cs @@ -0,0 +1,346 @@ +using AiteBar; + +namespace AiteBar.Tests; + +public sealed class TextProcessingServiceTests +{ + private readonly TextProcessingService _service = new(); + + [Theory] + [InlineData(TextProcessingMode.Proofread)] + [InlineData(TextProcessingMode.Typography)] + [InlineData(TextProcessingMode.Cleanup)] + public void GetSystemPrompt_ReturnsNonEmptyPrompt(TextProcessingMode mode) + { + string prompt = _service.GetSystemPrompt(mode); + Assert.False(string.IsNullOrWhiteSpace(prompt)); + } + + [Fact] + public void GetSystemPrompt_ReturnsDifferentPromptsForDifferentModes() + { + string proofread = _service.GetSystemPrompt(TextProcessingMode.Proofread); + string typography = _service.GetSystemPrompt(TextProcessingMode.Typography); + string cleanup = _service.GetSystemPrompt(TextProcessingMode.Cleanup); + + Assert.NotEqual(proofread, typography); + Assert.NotEqual(proofread, cleanup); + Assert.NotEqual(typography, cleanup); + } + + [Fact] + public void BuildRequest_CreatesValidRequest() + { + var request = _service.BuildRequest(TextProcessingMode.Proofread, "Hello world"); + + Assert.NotNull(request.Messages); + Assert.Equal(2, request.Messages.Count); + Assert.Equal("system", request.Messages[0].Role); + Assert.Equal("user", request.Messages[1].Role); + Assert.Contains("Hello world", request.Messages[1].Content); + Assert.True(request.RequireFreeModel); + Assert.True(request.RequireWritingModel); + Assert.True(request.MaxOutputTokens >= 1024); + Assert.True(request.RequiredContextTokens > request.MaxOutputTokens); + Assert.Equal(0.0, request.Temperature); + } + + [Theory] + [InlineData(TextProcessingMode.Proofread, 0.0)] + [InlineData(TextProcessingMode.Typography, 0.25)] + [InlineData(TextProcessingMode.Cleanup, 0.1)] + public void BuildRequest_UsesModeSpecificTemperature(TextProcessingMode mode, double expected) + { + AiChatRequest request = _service.BuildRequest(mode, "Text"); + + Assert.Equal(expected, request.Temperature); + } + + [Fact] + public void BuildRequest_EmptyText_CreatesValidRequest() + { + var request = _service.BuildRequest(TextProcessingMode.Cleanup, ""); + + Assert.NotNull(request.Messages); + Assert.Equal(2, request.Messages.Count); + } + + [Fact] + public void EstimateTokens_ReturnsPositiveForNonEmptyText() + { + int tokens = TextProcessingService.EstimateTokens("Hello world, this is a test."); + Assert.True(tokens > 0); + } + + [Fact] + public void EstimateTokens_ReturnsZeroForEmptyText() + { + Assert.Equal(0, TextProcessingService.EstimateTokens("")); + Assert.Equal(0, TextProcessingService.EstimateTokens((string?)null!)); + } + + [Fact] + public void EstimateTokens_LongerTextReturnsMoreTokens() + { + int shortTokens = TextProcessingService.EstimateTokens("Hi"); + int longTokens = TextProcessingService.EstimateTokens("This is a much longer sentence with many more words in it."); + Assert.True(longTokens > shortTokens); + } + + [Fact] + public void CleanResponse_EmptyString_ReturnsEmpty() + { + Assert.Equal(string.Empty, _service.CleanResponse("")); + Assert.Equal(string.Empty, _service.CleanResponse(" ")); + Assert.Equal(string.Empty, _service.CleanResponse((string?)null!)); + } + + [Fact] + public void CleanResponse_PlainText_ReturnsUnchanged() + { + string input = "This is plain text without any wrapping."; + Assert.Equal(input, _service.CleanResponse(input)); + } + + [Fact] + public void CleanResponse_PreservesCodeFence() + { + string input = "```\nCorrected text here\n```"; + string result = _service.CleanResponse(input); + Assert.Equal(input, result); + } + + [Fact] + public void CleanResponse_PreservesCodeFenceWithoutLanguageTag() + { + string input = "```\nSome result\n```"; + string result = _service.CleanResponse(input); + Assert.Equal(input, result); + } + + [Theory] + [InlineData("Исправленный текст: Hello")] + [InlineData("Оформленный текст: World")] + [InlineData("Очищенный текст: Test")] + [InlineData("Result: output")] + [InlineData("Corrected text: fix")] + [InlineData("Formatted text: fmt")] + [InlineData("Cleaned text: clean")] + public void CleanResponse_PreservesServiceLikePrefixes(string input) + { + string result = _service.CleanResponse(input); + Assert.Equal(input, result); + } + + [Theory] + [InlineData("\"quoted text\"", "\"quoted text\"")] + [InlineData("'single quoted'", "'single quoted'")] + public void CleanResponse_SingleLineInQuotes_KeepsQuotes(string input, string expected) + { + string result = _service.CleanResponse(input); + Assert.Equal(expected, result); + } + + [Fact] + public void CleanResponse_MultilineInQuotes_KeepsQuotes() + { + string input = "\"line1\nline2\""; + string result = _service.CleanResponse(input); + Assert.Equal(input, result); + } + + [Fact] + public void CleanResponse_WhitespaceAroundResult_IsTrimmed() + { + string input = " \n Cleaned text \n "; + string result = _service.CleanResponse(input); + Assert.Equal("Cleaned text", result); + } + + [Theory] + [InlineData("internal reasoning\nИсправленный текст.", "Исправленный текст.")] + [InlineData("internal reasoning\nИсправленный текст.", "Исправленный текст.")] + [InlineData("internal reasoning\nИсправленный текст.", "Исправленный текст.")] + [InlineData("internal reasoning\nИсправленный текст.", "Исправленный текст.")] + public void CleanResponse_RemovesClosedReasoningBlocks(string input, string expected) + { + Assert.Equal(expected, _service.CleanResponse(input)); + } + + [Fact] + public void CleanResponse_UnclosedThinkBlock_RecoversExplicitFinalAnswer() + { + const string original = "Рыбак достает из реки толстую русалку"; + const string response = + "Рыбак\n" + + "1. Analyze the input.\n" + + "Final string: Рыбак достает из реки толстую русалку.\n" + + "Output: matches response.\n"; + + Assert.Equal( + "Рыбак достает из реки толстую русалку.", + _service.CleanResponse(response, original)); + } + + [Fact] + public void CleanResponse_UnclosedReasoningWithoutRecoverableAnswer_ReturnsOriginalText() + { + const string original = "Исходный текст"; + Assert.Equal( + original, + _service.CleanResponse("Исхслужебное рассуждение", original)); + } + + [Fact] + public void CleanResponse_UnclosedReasoningRejectsDistantExplicitAnswer() + { + const string original = "Проверь исходный текст без замены смысла."; + const string response = + "служебное рассуждение\n" + + "Final output: Совершенно другой ответ о посторонней теме.\n"; + + Assert.Equal(original, _service.CleanResponse(response, original)); + } + + [Fact] + public void HideReasoningFromStreamingPreview_HidesUnclosedReasoningTail() + { + Assert.Equal( + "Готовый текст", + TextProcessingService.HideReasoningFromStreamingPreview( + "Готовый текстслужебное рассуждение")); + } + + [Fact] + public void MaxInputLength_Is50000() + { + Assert.Equal(50_000, TextProcessingService.MaxInputLength); + } + + [Fact] + public void BuildRequest_ProofreadMode_UsesCorrectPrompt() + { + var request = _service.BuildRequest(TextProcessingMode.Proofread, "text"); + Assert.Contains("орфографи", request.Messages[0].Content); + } + + [Fact] + public void BuildRequest_TypographyMode_UsesCorrectPrompt() + { + var request = _service.BuildRequest(TextProcessingMode.Typography, "text"); + Assert.Contains("типографическ", request.Messages[0].Content); + } + + [Fact] + public void BuildRequest_CleanupMode_UsesCorrectPrompt() + { + var request = _service.BuildRequest(TextProcessingMode.Cleanup, "text"); + Assert.Contains("очистк", request.Messages[0].Content); + } + + [Fact] + public void BuildRequest_WithMaxOutputTokens_RespectsMinClamp() + { + // MaxOutputTokens is clamped to [1024, 32768], so 500 becomes 1024 + var request = _service.BuildRequest(TextProcessingMode.Proofread, "text", maxOutputTokens: 500); + Assert.Equal(1024, request.MaxOutputTokens); + } + + [Fact] + public void BuildRequest_MaxOutputTokens_CannotExceed32768() + { + // For short text, outputBudget is small, so Clamp brings it to 1024 minimum + var request = _service.BuildRequest(TextProcessingMode.Proofread, "text", maxOutputTokens: 50000); + Assert.True(request.MaxOutputTokens <= 32768); + Assert.True(request.MaxOutputTokens >= 1024); + } + + [Fact] + public void BuildRequest_LongText_IncreasesOutputBudget() + { + string shortText = "hi"; + string longText = new string('x', 10000); + var shortReq = _service.BuildRequest(TextProcessingMode.Proofread, shortText); + var longReq = _service.BuildRequest(TextProcessingMode.Proofread, longText); + Assert.True(longReq.MaxOutputTokens >= shortReq.MaxOutputTokens); + Assert.True(longReq.RequiredContextTokens > shortReq.RequiredContextTokens); + } + + [Fact] + public void CleanResponse_CodeFenceWithLanguageTag_PreservesTag() + { + string input = "```python\ncode here\n```"; + string result = _service.CleanResponse(input); + Assert.Equal(input, result); + } + + [Fact] + public void CleanResponse_SingleLineNoNewline_KeepsQuotes() + { + string input = "\"just one line\""; + string result = _service.CleanResponse(input); + Assert.Equal("\"just one line\"", result); + } + + [Fact] + public void CleanResponse_GermanPrefixes_Preserved() + { + Assert.Equal("Korrigierter Text: text", _service.CleanResponse("Korrigierter Text: text")); + Assert.Equal("Formatierter Text: text", _service.CleanResponse("Formatierter Text: text")); + Assert.Equal("Bereinigter Text: text", _service.CleanResponse("Bereinigter Text: text")); + Assert.Equal("Ergebnis: text", _service.CleanResponse("Ergebnis: text")); + } + + [Fact] + public void CleanResponse_UkrainianPrefixes_Preserved() + { + Assert.Equal("Виправлений текст: text", _service.CleanResponse("Виправлений текст: text")); + Assert.Equal("Оформлений текст: text", _service.CleanResponse("Оформлений текст: text")); + Assert.Equal("Очищений текст: text", _service.CleanResponse("Очищений текст: text")); + } + + [Fact] + public void CleanResponse_OnlyWhitespace_ReturnsEmpty() + { + Assert.Equal(string.Empty, _service.CleanResponse("\n\n\n")); + Assert.Equal(string.Empty, _service.CleanResponse("\t\t")); + } + + [Fact] + public void EstimateTokens_TextLengthAffectsTokenCount() + { + string shortText = "hi"; + string longText = "This is a much longer sentence with many more words in it."; + int shortTokens = TextProcessingService.EstimateTokens(shortText); + int longTokens = TextProcessingService.EstimateTokens(longText); + Assert.True(longTokens > shortTokens); + } + + [Fact] + public void GetSystemPrompt_ProofreadPrompt_ContainsProhibitions() + { + string prompt = _service.GetSystemPrompt(TextProcessingMode.Proofread); + Assert.Contains("перефразировать", prompt); + Assert.Contains("UPPERCASE", prompt); + Assert.Contains("Верни только", prompt); + } + + [Fact] + public void GetSystemPrompt_TypographyPrompt_ContainsProhibitions() + { + string prompt = _service.GetSystemPrompt(TextProcessingMode.Typography); + Assert.Contains("менять слова", prompt); + Assert.Contains("исправлять орфографию или грамматику", prompt); + Assert.Contains("Сохраняй структуру абзацев", prompt); + Assert.Contains("Верни только", prompt); + } + + [Fact] + public void GetSystemPrompt_CleanupPrompt_ContainsProhibitions() + { + string prompt = _service.GetSystemPrompt(TextProcessingMode.Cleanup); + Assert.Contains("исправлять орфографию", prompt); + Assert.Contains("повторяется более двух раз", prompt); + Assert.Contains("Верни только", prompt); + } +} diff --git a/AiteBar.Tests/TextProcessingTokenEstimationTests.cs b/AiteBar.Tests/TextProcessingTokenEstimationTests.cs new file mode 100644 index 0000000..12a8ec4 --- /dev/null +++ b/AiteBar.Tests/TextProcessingTokenEstimationTests.cs @@ -0,0 +1,34 @@ +namespace AiteBar.Tests; + +public sealed class TextProcessingTokenEstimationTests +{ + [Fact] + public void EstimateTokens_UsesLatinCoefficient() + { + Assert.Equal(3, TextProcessingService.EstimateTokens("abcdefghij")); + } + + [Fact] + public void EstimateTokens_UsesCyrillicCoefficient() + { + Assert.Equal(4, TextProcessingService.EstimateTokens("абвгдеёжзи")); + } + + [Fact] + public void EstimateTokens_UsesMixedCoefficient() + { + Assert.Equal(3, TextProcessingService.EstimateTokens("abcабвгде")); + } + + [Fact] + public void EstimateTokens_UsesConservativeCjkCoefficient() + { + Assert.Equal(4, TextProcessingService.EstimateTokens("你好世界")); + } + + [Fact] + public void EstimateTokens_UsesConservativeOtherScriptCoefficient() + { + Assert.Equal(3, TextProcessingService.EstimateTokens("مرحبا")); + } +} diff --git a/AiteBar.Tests/TextProcessingUiStateTests.cs b/AiteBar.Tests/TextProcessingUiStateTests.cs new file mode 100644 index 0000000..1ccdbb9 --- /dev/null +++ b/AiteBar.Tests/TextProcessingUiStateTests.cs @@ -0,0 +1,132 @@ +using AiteBar; + +namespace AiteBar.Tests; + +public sealed class TextProcessingUiStateTests +{ + [Fact] + public void EmptyText_DisablesTextActionsAndProcessing() + { + TextProcessingUiState state = Create(text: string.Empty); + + Assert.Equal(0, state.CharacterCount); + Assert.Equal(0, state.WordCount); + Assert.False(state.CanProcess); + Assert.False(state.CanCopy); + Assert.False(state.CanClear); + } + + [Fact] + public void ValidTextAndModel_EnableProcessing() + { + TextProcessingUiState state = Create(text: "one two", hasEligibleModel: true); + + Assert.Equal(7, state.CharacterCount); + Assert.Equal(2, state.WordCount); + Assert.True(state.CanProcess); + Assert.True(state.CanCopy); + Assert.True(state.CanClear); + } + + [Fact] + public void WhitespaceOnly_DisablesProcessingButCanBeCleared() + { + TextProcessingUiState state = Create(text: " ", hasEligibleModel: true); + + Assert.False(state.CanProcess); + Assert.True(state.CanCopy); + Assert.True(state.CanClear); + } + + [Fact] + public void OversizedText_IsPreservedAndDisablesProcessing() + { + string text = new('x', TextProcessingService.MaxInputLength + 1); + TextProcessingUiState state = Create(text: text, hasEligibleModel: true); + + Assert.Equal(text.Length, state.CharacterCount); + Assert.True(state.IsOverLimit); + Assert.False(state.CanProcess); + Assert.True(state.CanCopy); + Assert.True(state.CanClear); + } + + [Fact] + public void MissingOrLoadingModels_DisablesProcessing() + { + Assert.False(Create(text: "text", hasEligibleModel: false).CanProcess); + Assert.False(Create(text: "text", hasEligibleModel: true, isLoadingModels: true).CanProcess); + } + + [Fact] + public void Processing_LeavesOnlyCancelAndCopyAvailable() + { + TextProcessingUiState state = Create( + text: "text", + hasEligibleModel: true, + isProcessing: true, + hasClipboardText: true, + hasSuccessfulResult: true); + + Assert.True(state.CanCancel); + Assert.True(state.CanCopy); + Assert.False(state.CanProcess); + Assert.False(state.CanEdit); + Assert.False(state.CanPaste); + Assert.False(state.CanClear); + Assert.False(state.CanRepeat); + Assert.False(state.CanSwitchVersion); + Assert.False(state.CanSelectMode); + Assert.False(state.CanSelectModel); + } + + [Fact] + public void SuccessfulResult_EnablesRepeatAndVersionSwitchingWhenIdle() + { + TextProcessingUiState state = Create( + text: "processed", + hasEligibleModel: true, + hasSuccessfulResult: true); + + Assert.True(state.CanRepeat); + Assert.True(state.CanSwitchVersion); + } + + [Fact] + public void Repeat_RequiresSuccessfulResultAndReadyEligibleModel() + { + Assert.False(Create(text: "text", hasEligibleModel: true).CanRepeat); + Assert.False(Create( + text: "processed", + hasEligibleModel: false, + hasSuccessfulResult: true).CanRepeat); + Assert.False(Create( + text: "processed", + hasEligibleModel: true, + isLoadingModels: true, + hasSuccessfulResult: true).CanRepeat); + } + + [Fact] + public void Paste_DependsOnClipboardTextAndIdleState() + { + Assert.False(Create(hasClipboardText: false).CanPaste); + Assert.True(Create(hasClipboardText: true).CanPaste); + Assert.False(Create(hasClipboardText: true, isProcessing: true).CanPaste); + } + + private static TextProcessingUiState Create( + string text = "", + bool isProcessing = false, + bool isLoadingModels = false, + bool hasEligibleModel = false, + bool hasClipboardText = false, + bool hasSuccessfulResult = false) => + TextProcessingUiState.Create(new TextProcessingUiStateInput( + text, + isProcessing, + isLoadingModels, + hasEligibleModel, + hasClipboardText, + hasSuccessfulResult)); +} diff --git a/AiteBar.Tests/TextProcessingUndoHistoryTests.cs b/AiteBar.Tests/TextProcessingUndoHistoryTests.cs new file mode 100644 index 0000000..47ba4b2 --- /dev/null +++ b/AiteBar.Tests/TextProcessingUndoHistoryTests.cs @@ -0,0 +1,43 @@ +namespace AiteBar.Tests; + +public sealed class TextProcessingUndoHistoryTests +{ + [Fact] + public void UndoAndRedo_RestoreRecordedOperation() + { + var history = new TextProcessingUndoHistory(); + history.Record("before"); + + Assert.True(history.TryUndo("after", out string before)); + Assert.Equal("before", before); + Assert.True(history.TryRedo(before, out string after)); + Assert.Equal("after", after); + } + + [Fact] + public void NewRecord_ClearsRedo() + { + var history = new TextProcessingUndoHistory(); + history.Record("one"); + Assert.True(history.TryUndo("two", out _)); + + history.Record("three"); + + Assert.False(history.TryRedo("four", out _)); + } + + [Fact] + public void Capacity_DropsOldestState() + { + var history = new TextProcessingUndoHistory(2); + history.Record("one"); + history.Record("two"); + history.Record("three"); + + Assert.True(history.TryUndo("four", out string three)); + Assert.Equal("three", three); + Assert.True(history.TryUndo(three, out string two)); + Assert.Equal("two", two); + Assert.False(history.TryUndo(two, out _)); + } +} diff --git a/AiteBar.Tests/TextProcessingVisualContractTests.cs b/AiteBar.Tests/TextProcessingVisualContractTests.cs new file mode 100644 index 0000000..1e3f7f3 --- /dev/null +++ b/AiteBar.Tests/TextProcessingVisualContractTests.cs @@ -0,0 +1,215 @@ +using System.IO; + +namespace AiteBar.Tests; + +public sealed class TextProcessingVisualContractTests +{ + [Fact] + public void Window_ContainsReleaseActionsAndTopSystemStatus() + { + string xaml = ReadWindowXaml(); + + Assert.Contains("x:Name=\"BtnPaste\"", xaml); + Assert.Contains("x:Name=\"BtnCopy\"", xaml); + Assert.Contains("x:Name=\"BtnRepeat\"", xaml); + Assert.Contains("x:Name=\"BtnToggleVersion\"", xaml); + Assert.Contains("x:Name=\"BtnShowDiff\"", xaml); + Assert.Contains("x:Name=\"BtnClear\"", xaml); + Assert.Contains("x:Name=\"BtnProcess\"", xaml); + Assert.Contains("x:Name=\"TxtModelState\" Grid.Column=\"1\"", xaml); + Assert.DoesNotContain("x:Name=\"ModelStateBorder\"", xaml); + Assert.DoesNotContain("x:Name=\"InfoStatusBorder\"", xaml); + Assert.DoesNotContain("x:Name=\"TxtInfoMessage\"", xaml); + Assert.Contains("TextProcessing_DataWarning", xaml); + } + + [Fact] + public void Window_PreservesOversizedTextAndUsesFixedEditorWidth() + { + string xaml = ReadWindowXaml(); + + Assert.DoesNotContain("MaxLength=", xaml); + Assert.Contains("", xaml); + Assert.DoesNotContain("WindowState=\"Maximized\"", xaml); + Assert.Contains("Width=\"1280\" Height=\"840\"", xaml); + Assert.Contains("MinWidth=\"1000\" MinHeight=\"700\"", xaml); + Assert.Contains("", xaml); + } + + [Fact] + public void Window_UsesCompactAlignedControlsAndSystemGlyphs() + { + string xaml = ReadWindowXaml(); + + Assert.Contains("Padding=\"12,10\"", xaml); + Assert.Contains("Margin=\"13,11,0,0\"", xaml); + Assert.Contains("x:Name=\"PART_ContentHost\" Margin=\"0\"", xaml); + Assert.DoesNotContain("Margin=\"{TemplateBinding Padding}\"", xaml); + Assert.Contains("Property=\"local:KeyboardFocusVisualService.ShowKeyboardFocusCue\" Value=\"True\"", xaml); + Assert.Contains("TargetName=\"EditorFocusBorder\" Property=\"BorderBrush\" Value=\"#3ABEFF\"", xaml); + Assert.Contains("", xaml); + Assert.Contains("FontFamily=\"Segoe MDL2 Assets\"", xaml); + Assert.Contains("FontSize=\"18\" FontWeight=\"Normal\"", xaml); + Assert.Contains("x:Name=\"FooterCommandColumn\" Width=\"150\"", xaml); + Assert.Contains("x:Name=\"RailCommandColumn\" Width=\"150\"", xaml); + Assert.Contains(" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AiteBar/TextProcessingWindow.xaml.cs b/AiteBar/TextProcessingWindow.xaml.cs new file mode 100644 index 0000000..335513a --- /dev/null +++ b/AiteBar/TextProcessingWindow.xaml.cs @@ -0,0 +1,1397 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Runtime.Versioning; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using Forms = System.Windows.Forms; + +namespace AiteBar; + +[SupportedOSPlatform("windows6.1")] +public partial class TextProcessingWindow : DarkWindow +{ + private const double PreferredWidth = 1280; + private const double PreferredHeight = 840; + private const double PreferredMinWidth = 1000; + private const double PreferredMinHeight = 700; + private const double EditorWidth = 738; + private const double CommandGap = 16; + private const double HorizontalWindowInsets = 80; + private const double WorkAreaRatio = 0.9; + private static readonly TimeSpan StreamingUiUpdateInterval = TimeSpan.FromMilliseconds(50); + + private readonly TextProcessingService _service; + private readonly AppSettingsService _settingsService; + private readonly AiGateway _gateway; + private readonly MainWindow? _mainWindow; + private readonly ObservableCollection _models = []; + private readonly TextProcessingUndoHistory _operationHistory = new(10); + private readonly DispatcherTimer _progressTimer; + private CancellationTokenSource? _processingCts; + private CancellationTokenSource? _loadModelsCts; + private bool _isLoadingState = true; + private bool _isLoadingModels; + private bool _isApplyingEditorText; + private bool _isDirty; + private bool _isProcessing; + private bool _hasClipboardText; + private bool _hasEligibleModel; + private bool _hasSuccessfulResult; + private bool _isShowingOriginal; + private bool _isShowingDiff; + private bool _isModifiedManually; + private bool _hasCopiedResult; + private string _lastUsedModelDisplay = string.Empty; + private string _inlineInfoStatus = string.Empty; + private TextProcessingMode _currentMode = TextProcessingMode.Proofread; + private string _originalText = string.Empty; + private string _processedText = string.Empty; + private bool _isAutoModel = true; + private string? _selectedProviderId; + private string? _selectedModelId; + private string _lastOriginalText = string.Empty; + private TextProcessingMode _lastMode; + private bool _lastWasAutoModel = true; + private string? _lastProviderId; + private string? _lastModelId; + private double _requiredMinWidth = PreferredMinWidth; + private DateTimeOffset _processingStartedAt; + private bool _isProgressStatusVisible; + + public TextProcessingWindow( + TextProcessingService service, + AppSettingsService settingsService, + MainWindow? mainWindow = null) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + _mainWindow = mainWindow; + _gateway = new AiGateway(settingsService); + _currentMode = ParseSavedMode(settingsService.Settings.TextProcessingLastMode); + InitializeComponent(); + _progressTimer = new DispatcherTimer(TimeSpan.FromSeconds(1), DispatcherPriority.Background, (_, _) => + UpdateProcessingProgress(), Dispatcher); + CmbModels.ItemsSource = _models; + ApplyModeToUi(); + RefreshUiState(); + UpdateCommandButtonLayout(); + } + + public void ShowNearPanel(AppSettingsService settingsService) + { + RestoreWindowState(settingsService.Settings); + Show(); + Activate(); + FocusEditor(); + } + + internal void RestoreFromAiteBar() + { + if (WindowState == WindowState.Minimized) + { + WindowState = WindowState.Normal; + } + if (!IsVisible) + { + Show(); + } + Activate(); + FocusEditor(); + } + + private async void Window_Loaded(object sender, RoutedEventArgs e) + { + _isLoadingState = true; + ApplyModeToUi(); + RefreshClipboardAvailability(showError: false); + _loadModelsCts = new CancellationTokenSource(); + try + { + await LoadModelsAsync(_loadModelsCts.Token); + } + catch (OperationCanceledException) + { + // Closing while the model catalogue loads is expected. + } + catch (Exception ex) + { + Logger.Log(ex); + SetStatus(LocalizationService.Get("TextProcessing_ErrorNoModels")); + } + finally + { + _isLoadingModels = false; + _isLoadingState = false; + _loadModelsCts?.Dispose(); + _loadModelsCts = null; + RefreshUiState(); + FocusEditor(); + } + } + + private void Window_Activated(object? sender, EventArgs e) + { + RefreshClipboardAvailability(showError: false); + RefreshUiState(); + } + + private void Window_Closing(object? sender, CancelEventArgs e) + { + bool hasContent = !string.IsNullOrWhiteSpace(TxtEditor.Text); + bool needsWarning = hasContent && (_isDirty || (_hasSuccessfulResult && !_hasCopiedResult)); + if (needsWarning) + { + bool close = new DarkDialog(LocalizationService.Get("TextProcessing_ConfirmClose"), isConfirm: true) + { + Owner = this + }.ShowDialog() == true; + if (!close) + { + e.Cancel = true; + return; + } + } + SaveWindowState(); + _processingCts?.Cancel(); + _loadModelsCts?.Cancel(); + } + + private void Window_StateChanged(object? sender, EventArgs e) + { + if (!_isLoadingState && WindowState != WindowState.Minimized) + { + SaveWindowState(); + } + } + + private void Window_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (!_isLoadingState && WindowState == WindowState.Normal) + { + SaveWindowState(); + } + } + + private void Window_LocationChanged(object? sender, EventArgs e) + { + if (!_isLoadingState && WindowState == WindowState.Normal && Left > -10000 && Top > -10000) + { + SaveWindowState(); + } + } + + private async void Window_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.Control) + { + e.Handled = true; + if (!_isProcessing) + { + await ProcessAsync(repeatLast: false); + } + } + else if (e.Key == Key.Escape && _isProcessing) + { + e.Handled = true; + CancelProcessing(); + } + else if (e.Key == Key.Z && Keyboard.Modifiers == ModifierKeys.Control) + { + e.Handled = true; + UndoEditor(); + } + else if (e.Key == Key.Y && Keyboard.Modifiers == ModifierKeys.Control) + { + e.Handled = true; + RedoEditor(); + } + } + + private void ModeTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoadingState || _isProcessing || ModeTabs.SelectedItem is not TabItem { Tag: string tag }) + { + return; + } + _currentMode = tag switch + { + "Proofread" => TextProcessingMode.Proofread, + "Typography" => TextProcessingMode.Typography, + "Cleanup" => TextProcessingMode.Cleanup, + _ => _currentMode + }; + SaveModeSelection(); + ApplyModeToUi(); + RefreshUiState(); + } + + private void TxtEditor_TextChanged(object sender, TextChangedEventArgs e) + { + if (_isApplyingEditorText) + { + return; + } + if (_hasSuccessfulResult) + { + if (!_isShowingOriginal) + { + _processedText = TxtEditor.Text; + _isModifiedManually = true; + } + } + _isDirty = true; + SetStatus(string.Empty); + ClearInfoStatus(); + RefreshUiState(); + } + + private void CmbModels_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoadingState || _isLoadingModels || CmbModels.SelectedItem is not ModelItem item) + { + return; + } + _isAutoModel = item.ModelId == null; + _selectedProviderId = item.ProviderId; + _selectedModelId = item.ModelId; + SaveModelSelection(); + SetStatus(string.Empty); + RefreshUiState(); + } + + private void CmbModels_DropDownOpened(object sender, EventArgs e) + { + if (CmbModels.Template.FindName("DropDownBorder", CmbModels) is FrameworkElement dropDown) + { + dropDown.MinWidth = 0; + dropDown.Width = CmbModels.ActualWidth; + dropDown.MaxWidth = CmbModels.ActualWidth; + } + } + + private async void BtnProcess_Click(object sender, RoutedEventArgs e) + { + if (_isProcessing) + { + CancelProcessing(); + return; + } + await ProcessAsync(repeatLast: false); + } + + private async void BtnRepeat_Click(object sender, RoutedEventArgs e) => + await ProcessAsync(repeatLast: true); + + private void BtnToggleVersion_Click(object sender, RoutedEventArgs e) + { + if (_isProcessing || !_hasSuccessfulResult) + { + return; + } + + _isShowingOriginal = _isShowingDiff || !_isShowingOriginal; + _isShowingDiff = false; + SetEditorText(_isShowingOriginal ? _originalText : _processedText); + RefreshUiState(); + FocusEditor(); + } + + private void BtnClear_Click(object sender, RoutedEventArgs e) + { + if (!string.IsNullOrWhiteSpace(TxtEditor.Text)) + { + bool confirmed = new DarkDialog(LocalizationService.Get("TextProcessing_ConfirmClear"), isConfirm: true) + { + Owner = this + }.ShowDialog() == true; + if (!confirmed) + { + return; + } + } + Clear(); + } + + private void BtnPaste_Click(object sender, RoutedEventArgs e) + { + try + { + if (!Clipboard.ContainsText()) + { + RefreshClipboardAvailability(showError: false); + RefreshUiState(); + return; + } + string clipboardText = Clipboard.GetText(); + (string updatedText, int caretIndex) = InsertAtSelection( + TxtEditor.Text, + TxtEditor.SelectionStart, + TxtEditor.SelectionLength, + clipboardText); + ResetResultHistory(); + SetEditorText(updatedText, caretIndex, recordUndo: true); + _isDirty = true; + SetStatus(string.Empty); + RefreshClipboardAvailability(showError: false); + RefreshUiState(); + FocusEditor(); + } + catch (Exception ex) + { + Logger.Log(ex); + SetStatus(LocalizationService.Get("TextProcessing_ErrorClipboard")); + RefreshClipboardAvailability(showError: false); + RefreshUiState(); + } + } + + private void BtnCopy_Click(object sender, RoutedEventArgs e) + { + try + { + if (!string.IsNullOrWhiteSpace(TxtEditor.Text)) + { + Clipboard.SetText(TxtEditor.Text); + if (_hasSuccessfulResult) + { + _hasCopiedResult = true; + } + SetStatus(LocalizationService.Get("TextProcessing_Copied")); + } + RefreshClipboardAvailability(showError: false); + RefreshUiState(); + } + catch (Exception ex) + { + Logger.Log(ex); + SetStatus(LocalizationService.Get("TextProcessing_ErrorClipboard")); + } + } + + private void BtnOpenSettings_Click(object sender, RoutedEventArgs e) + { + if (_mainWindow != null) + { + _ = _mainWindow.ShowAppSettingsWindow(AppSettingsSection.AiProviders); + } + else + { + SetStatus(LocalizationService.Get("TextProcessing_ErrorNoModels")); + } + } + + private async void BtnRefreshModels_Click(object sender, RoutedEventArgs e) + { + if (_isLoadingModels || _isProcessing) + { + return; + } + _loadModelsCts?.Cancel(); + _loadModelsCts?.Dispose(); + var refreshCts = new CancellationTokenSource(); + _loadModelsCts = refreshCts; + try + { + foreach (AiConnectionSettings connection in + (_settingsService.Settings.Ai?.Connections ?? []).Where(connection => connection.IsEnabled)) + { + _gateway.InvalidateModelCache(connection.Id); + } + await LoadModelsAsync(refreshCts.Token); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.Log(ex); + SetStatus(LocalizationService.Get("TextProcessing_ErrorNoModels")); + } + finally + { + if (ReferenceEquals(_loadModelsCts, refreshCts)) + { + _isLoadingModels = false; + refreshCts.Dispose(); + _loadModelsCts = null; + RefreshUiState(); + } + } + } + + private void BtnShowDiff_Click(object sender, RoutedEventArgs e) + { + if (_isProcessing || !_hasSuccessfulResult) + { + return; + } + + _isShowingDiff = !_isShowingDiff; + _isShowingOriginal = false; + if (_isShowingDiff) + { + RenderDiff(); + } + else + { + SetEditorText(_processedText); + } + RefreshUiState(); + } + + private async Task ProcessAsync(bool repeatLast) + { + if (_isProcessing) + { + return; + } + + string input; + TextProcessingMode mode; + bool useAutoModel; + string? providerId; + string? modelId; + if (repeatLast) + { + if (!_hasSuccessfulResult || string.IsNullOrWhiteSpace(_lastOriginalText)) + { + return; + } + input = _lastOriginalText; + mode = _lastMode; + useAutoModel = _lastWasAutoModel; + providerId = _lastProviderId; + modelId = _lastModelId; + if (!useAutoModel && !TrySelectModel(providerId, modelId)) + { + SetStatus(LocalizationService.Get("TextProcessing_ModelUnavailable")); + return; + } + _currentMode = mode; + _isAutoModel = useAutoModel; + if (useAutoModel) + { + CmbModels.SelectedIndex = 0; + _selectedProviderId = null; + _selectedModelId = null; + } + ApplyModeToUi(); + } + else + { + input = TxtEditor.Text; + mode = _currentMode; + useAutoModel = _isAutoModel; + providerId = useAutoModel ? null : _selectedProviderId; + modelId = useAutoModel ? null : _selectedModelId; + if (!GetUiState().CanProcess) + { + if (string.IsNullOrWhiteSpace(input)) + { + SetStatus(LocalizationService.Get("TextProcessing_ErrorEmptyInput")); + } + else if (!_hasEligibleModel) + { + SetStatus(LocalizationService.Get("TextProcessing_ErrorNoModels")); + } + return; + } + if (_hasSuccessfulResult && !_isShowingOriginal && _isModifiedManually) + { + bool replaceEditedResult = new DarkDialog(LocalizationService.Get("TextProcessing_ConfirmRepeat"), isConfirm: true) + { + Owner = this + }.ShowDialog() == true; + if (!replaceEditedResult) + { + return; + } + } + } + + ProtectedText protectedInput = _service.ProtectTechnicalFragments(input); + AiChatRequest request = _service.BuildRequest(mode, protectedInput.Text); + ModelItem? selected = null; + if (!useAutoModel) + { + selected = FindModel(providerId, modelId); + if (selected == null) + { + SetStatus(LocalizationService.Get("TextProcessing_ModelUnavailable")); + return; + } + } + if (useAutoModel) + { + if (!_models.Any(model => + model.ModelId != null && + (!model.ContextLength.HasValue || request.RequiredContextTokens <= model.ContextLength.Value))) + { + SetStatus(LocalizationService.Get("TextProcessing_ErrorContextOverflow")); + return; + } + } + else + { + if (selected!.ContextLength.HasValue && request.RequiredContextTokens > selected.ContextLength.Value) + { + SetStatus(LocalizationService.Get("TextProcessing_ErrorContextOverflow")); + return; + } + request = CopyRequestWithModel(request, providerId, modelId); + } + + string textShownBeforeRequest = TxtEditor.Text; + SetStatus(string.Empty); + ClearInfoStatus(); + _isShowingOriginal = false; + _isShowingDiff = false; + _isProcessing = true; + _isModifiedManually = false; + _hasCopiedResult = false; + _processingCts = new CancellationTokenSource(); + StartProcessingProgress(); + RefreshUiState(); + try + { + AiGatewayStream response = await _gateway.GenerateStreamingAsync(request, _processingCts.Token); + var streamedResponse = new StringBuilder(); + bool receivedContent = false; + long lastUiUpdate = 0; + await foreach (string chunk in response.Chunks) + { + streamedResponse.Append(chunk); + if (!receivedContent) + { + receivedContent = true; + } + if (lastUiUpdate == 0 || + Stopwatch.GetElapsedTime(lastUiUpdate) >= StreamingUiUpdateInterval) + { + string preview = BuildStreamingPreview(streamedResponse.ToString(), protectedInput); + SetEditorText(preview); + lastUiUpdate = Stopwatch.GetTimestamp(); + } + } + string cleanedProtected = _service.CleanResponse( + streamedResponse.ToString(), + protectedInput.Text); + string cleaned = TextProcessingService.RestoreTechnicalFragments( + cleanedProtected, + protectedInput, + requireAllMarkers: true); + if (string.IsNullOrWhiteSpace(cleaned)) + { + SetStatus(LocalizationService.Get("TextProcessing_ErrorEmptyResponse")); + return; + } + _originalText = input; + _processedText = cleaned; + _hasSuccessfulResult = true; + _isShowingOriginal = false; + _isShowingDiff = false; + _isModifiedManually = false; + _lastOriginalText = input; + _lastMode = mode; + _lastWasAutoModel = useAutoModel; + _lastProviderId = providerId; + _lastModelId = modelId; + ModelItem? usedModel = FindModel(response.ProviderId, response.ModelId); + _lastUsedModelDisplay = usedModel?.Display ?? response.ModelId; + if (!string.Equals(textShownBeforeRequest, cleaned, StringComparison.Ordinal)) + { + _operationHistory.Record(textShownBeforeRequest); + } + SetEditorText(cleaned); + _isDirty = false; + StopProcessingProgress(); + if (!string.IsNullOrEmpty(_lastUsedModelDisplay)) + { + SetInfoStatus(LocalizationService.Format("TextProcessing_ModelUsed", _lastUsedModelDisplay)); + } + } + catch (OperationCanceledException) when (_processingCts?.IsCancellationRequested == true) + { + SetEditorText(textShownBeforeRequest); + SetStatus(LocalizationService.Get("TextProcessing_ErrorCancellation")); + } + catch (OperationCanceledException ex) + { + Logger.Log(ex); + SetEditorText(textShownBeforeRequest); + SetStatus(LocalizationService.Get("TextProcessing_ErrorTimeout")); + } + catch (NoAvailableConnectionException ex) + { + Logger.Log(ex); + SetEditorText(textShownBeforeRequest); + SetStatus(ex.InnerException switch + { + AiProviderHttpException providerError => GetProviderError(providerError), + HttpRequestException => LocalizationService.Get("TextProcessing_ErrorNetwork"), + TimeoutException => LocalizationService.Get("TextProcessing_ErrorTimeout"), + _ => LocalizationService.Get("TextProcessing_ErrorNoModels") + }); + } + catch (AiProviderHttpException ex) + { + Logger.Log(ex); + SetEditorText(textShownBeforeRequest); + SetStatus(GetProviderError(ex)); + } + catch (TimeoutException ex) + { + Logger.Log(ex); + SetEditorText(textShownBeforeRequest); + SetStatus(LocalizationService.Get("TextProcessing_ErrorTimeout")); + } + catch (HttpRequestException ex) + { + Logger.Log(ex); + SetEditorText(textShownBeforeRequest); + SetStatus(LocalizationService.Get("TextProcessing_ErrorNetwork")); + } + catch (Exception ex) + { + Logger.Log(ex); + SetEditorText(textShownBeforeRequest); + SetStatus(LocalizationService.Get("TextProcessing_ErrorGeneric")); + } + finally + { + StopProcessingProgress(); + _isProcessing = false; + _processingCts?.Dispose(); + _processingCts = null; + RefreshUiState(); + FocusEditor(); + } + } + + private static AiChatRequest CopyRequestWithModel( + AiChatRequest request, + string? providerId, + string? modelId) => new() + { + Messages = request.Messages, + RequiredCapabilities = request.RequiredCapabilities, + RequireFreeModel = request.RequireFreeModel, + RequireWritingModel = request.RequireWritingModel, + RequireExactModel = true, + PreferredProviderId = providerId, + PreferredModelId = modelId, + RequiredContextTokens = request.RequiredContextTokens, + MaxOutputTokens = request.MaxOutputTokens, + Temperature = request.Temperature + }; + + private static string GetProviderError(AiProviderHttpException ex) => ex.StatusCode switch + { + System.Net.HttpStatusCode.Unauthorized => LocalizationService.Get("TextProcessing_ErrorUnauthorized"), + System.Net.HttpStatusCode.Forbidden => LocalizationService.Get("TextProcessing_ErrorForbidden"), + System.Net.HttpStatusCode.PaymentRequired => LocalizationService.Get("TextProcessing_ErrorQuota"), + System.Net.HttpStatusCode.TooManyRequests => LocalizationService.Get("TextProcessing_ErrorRateLimit"), + _ when (int)ex.StatusCode >= 500 => LocalizationService.Get("TextProcessing_ErrorUnavailable"), + _ => LocalizationService.Get("TextProcessing_ErrorGeneric") + }; + + private void CancelProcessing() => _processingCts?.Cancel(); + + private void StartProcessingProgress() + { + _processingStartedAt = DateTimeOffset.Now; + _isProgressStatusVisible = true; + UpdateProcessingProgress(); + _progressTimer.Start(); + } + + private void UpdateProcessingProgress() + { + if (!_isProcessing || !_isProgressStatusVisible) + { + return; + } + int seconds = Math.Max(0, (int)(DateTimeOffset.Now - _processingStartedAt).TotalSeconds); + SetInfoStatus(LocalizationService.Format("TextProcessing_Progress", seconds)); + } + + private void StopProcessingProgress() + { + _progressTimer.Stop(); + if (_isProgressStatusVisible) + { + _isProgressStatusVisible = false; + ClearInfoStatus(); + } + } + + private void Clear() + { + ResetResultHistory(); + SetEditorText(string.Empty); + _operationHistory.Clear(); + _isDirty = false; + SetStatus(string.Empty); + RefreshUiState(); + FocusEditor(); + } + + private void ResetResultHistory() + { + _originalText = string.Empty; + _processedText = string.Empty; + _lastOriginalText = string.Empty; + _hasSuccessfulResult = false; + _isShowingOriginal = false; + _isShowingDiff = false; + _isModifiedManually = false; + _hasCopiedResult = false; + ClearInfoStatus(); + } + + private void SetEditorText(string text, int? caretIndex = null, bool recordUndo = false) + { + text ??= string.Empty; + if (recordUndo && !string.Equals(TxtEditor.Text, text, StringComparison.Ordinal)) + { + _operationHistory.Record(TxtEditor.Text); + } + + _isApplyingEditorText = true; + try + { + bool restoreUndo = TxtEditor.IsUndoEnabled; + TxtEditor.IsUndoEnabled = false; + TxtEditor.Text = text; + TxtEditor.IsUndoEnabled = restoreUndo; + TxtEditor.CaretIndex = Math.Clamp(caretIndex ?? TxtEditor.Text.Length, 0, TxtEditor.Text.Length); + } + finally + { + _isApplyingEditorText = false; + } + } + + private void UndoEditor() + { + if (_isProcessing || _isShowingOriginal) + { + return; + } + if (TxtEditor.CanUndo) + { + TxtEditor.Undo(); + return; + } + if (_operationHistory.TryUndo(TxtEditor.Text, out string previous)) + { + ResetResultHistory(); + SetEditorText(previous); + _isDirty = true; + SetStatus(string.Empty); + RefreshUiState(); + FocusEditor(); + } + } + + private void RedoEditor() + { + if (_isProcessing || _isShowingOriginal) + { + return; + } + if (TxtEditor.CanRedo) + { + TxtEditor.Redo(); + return; + } + if (_operationHistory.TryRedo(TxtEditor.Text, out string next)) + { + ResetResultHistory(); + SetEditorText(next); + _isDirty = true; + SetStatus(string.Empty); + RefreshUiState(); + FocusEditor(); + } + } + + internal static string BuildStreamingPreview(string rawText, ProtectedText protectedText) + { + string visibleText = TextProcessingService.HideReasoningFromStreamingPreview( + rawText ?? string.Empty); + int partialMarkerLength = 0; + foreach (string marker in protectedText.Fragments.Keys) + { + int limit = Math.Min(visibleText.Length, marker.Length - 1); + for (int length = limit; length >= 2; length--) + { + if (marker.StartsWith(visibleText[^length..], StringComparison.Ordinal)) + { + partialMarkerLength = Math.Max(partialMarkerLength, length); + break; + } + } + } + if (partialMarkerLength > 0) + { + visibleText = visibleText[..^partialMarkerLength]; + } + return TextProcessingService.RestoreTechnicalFragments(visibleText, protectedText); + } + + internal static (string Text, int CaretIndex) InsertAtSelection( + string source, + int selectionStart, + int selectionLength, + string insertion) + { + source ??= string.Empty; + insertion ??= string.Empty; + int start = Math.Clamp(selectionStart, 0, source.Length); + int length = Math.Clamp(selectionLength, 0, source.Length - start); + string result = source.Remove(start, length).Insert(start, insertion); + return (result, start + insertion.Length); + } + + private TextProcessingUiState GetUiState() => TextProcessingUiState.Create(new TextProcessingUiStateInput( + TxtEditor.Text, + _isProcessing, + _isLoadingModels, + _hasEligibleModel, + _hasClipboardText, + _hasSuccessfulResult)); + + private void RefreshUiState() + { + if (!IsInitialized) + { + return; + } + TextProcessingUiState state = GetUiState(); + TxtPlaceholder.Visibility = state.CharacterCount == 0 ? Visibility.Visible : Visibility.Collapsed; + TxtCounters.Text = $"{LocalizationService.Format("TextProcessing_Characters", state.CharacterCount)} · {LocalizationService.Format("TextProcessing_Words", state.WordCount)}"; + TxtCounters.Foreground = state.IsOverLimit + ? (Brush)FindResource("TextProcessingWarningBrush") + : (Brush)FindResource("MutedText"); + AutomationProperties.SetName(TxtCounters, TxtCounters.Text); + LimitBorder.Visibility = state.IsOverLimit ? Visibility.Visible : Visibility.Collapsed; + TxtEditor.IsEnabled = state.CanEdit; + TxtEditor.IsReadOnly = _isShowingOriginal; + TxtEditor.Visibility = _isShowingDiff ? Visibility.Collapsed : Visibility.Visible; + DiffViewer.Visibility = _isShowingDiff ? Visibility.Visible : Visibility.Collapsed; + ModeProofread.IsEnabled = state.CanSelectMode; + ModeTypography.IsEnabled = state.CanSelectMode; + ModeCleanup.IsEnabled = state.CanSelectMode; + CmbModels.IsEnabled = state.CanSelectModel; + BtnRefreshModels.IsEnabled = !_isProcessing && !_isLoadingModels; + BtnPaste.IsEnabled = state.CanPaste; + BtnCopy.IsEnabled = state.CanCopy; + BtnClear.IsEnabled = state.CanClear; + BtnRepeat.IsEnabled = state.CanRepeat; + BtnToggleVersion.IsEnabled = state.CanSwitchVersion; + BtnShowDiff.IsEnabled = state.CanSwitchVersion; + ToggleVersionLabel.Text = LocalizationService.Get(_isShowingOriginal + ? "TextProcessing_ButtonShowResult" + : "TextProcessing_ButtonShowOriginal"); + AutomationProperties.SetName(BtnToggleVersion, ToggleVersionLabel.Text); + BtnProcess.IsEnabled = state.CanCancel || state.CanProcess; + ProcessButtonLabel.Text = _isProcessing + ? LocalizationService.Get("TextProcessing_ButtonCancel") + : LocalizationService.Get("TextProcessing_ButtonProcess"); + AutomationProperties.SetName(BtnProcess, ProcessButtonLabel.Text); + + if (_isLoadingModels) + { + TxtModelState.Text = LocalizationService.Get("TextProcessing_ModelLoading"); + TxtModelState.Foreground = (Brush)FindResource("MutedText"); + TxtModelState.ToolTip = TxtModelState.Text; + TxtModelState.Visibility = Visibility.Visible; + BtnOpenSettings.Visibility = Visibility.Collapsed; + } + else if (!_hasEligibleModel) + { + TxtModelState.Text = LocalizationService.Get("TextProcessing_ErrorNoModels"); + TxtModelState.Foreground = (Brush)FindResource("TextProcessingWarningBrush"); + TxtModelState.ToolTip = TxtModelState.Text; + TxtModelState.Visibility = Visibility.Visible; + BtnOpenSettings.Visibility = Visibility.Visible; + } + else if (!string.IsNullOrWhiteSpace(_inlineInfoStatus)) + { + TxtModelState.Text = _inlineInfoStatus; + TxtModelState.Foreground = (Brush)FindResource("MutedText"); + TxtModelState.ToolTip = _inlineInfoStatus; + TxtModelState.Visibility = Visibility.Visible; + BtnOpenSettings.Visibility = Visibility.Collapsed; + } + else + { + TxtModelState.Visibility = Visibility.Collapsed; + TxtModelState.ToolTip = null; + BtnOpenSettings.Visibility = Visibility.Collapsed; + } + } + + private void UpdateCommandButtonLayout() + { + Button[] buttons = [BtnPaste, BtnCopy, BtnRepeat, BtnToggleVersion, BtnShowDiff, BtnClear, BtnProcess]; + double commandWidth = buttons.Max(MeasureButtonWidth); + commandWidth = Math.Max(commandWidth, MeasureButtonWidthForLabels( + BtnToggleVersion, + ToggleVersionLabel, + LocalizationService.Get("TextProcessing_ButtonShowOriginal"), + LocalizationService.Get("TextProcessing_ButtonShowResult"))); + commandWidth = Math.Max(commandWidth, MeasureButtonWidthForLabels( + BtnProcess, + ProcessButtonLabel, + LocalizationService.Get("TextProcessing_ButtonProcess"), + LocalizationService.Get("TextProcessing_ButtonCancel"))); + commandWidth = Math.Ceiling(commandWidth); + + foreach (Button button in buttons) + { + button.Width = commandWidth; + } + FooterCommandColumn.Width = new GridLength(commandWidth); + RailCommandColumn.Width = new GridLength(commandWidth); + ContentHost.Width = EditorWidth + CommandGap + commandWidth; + _requiredMinWidth = Math.Max(PreferredMinWidth, ContentHost.Width + HorizontalWindowInsets); + MinWidth = _requiredMinWidth; + } + + private static double MeasureButtonWidthForLabels( + Button button, + TextBlock label, + params string[] labels) + { + string originalText = label.Text; + double width = 0; + foreach (string text in labels) + { + label.Text = text; + label.InvalidateMeasure(); + label.Measure(new System.Windows.Size(double.PositiveInfinity, double.PositiveInfinity)); + if (button.Content is FrameworkElement content) + { + content.InvalidateMeasure(); + } + width = Math.Max(width, MeasureButtonWidth(button)); + } + label.Text = originalText; + label.InvalidateMeasure(); + return width; + } + + private static double MeasureButtonWidth(Button button) + { + if (button.Content is not FrameworkElement content) + { + return button.MinWidth; + } + content.Measure(new System.Windows.Size(double.PositiveInfinity, double.PositiveInfinity)); + return Math.Max( + button.MinWidth, + content.DesiredSize.Width + + button.Padding.Left + + button.Padding.Right + + button.BorderThickness.Left + + button.BorderThickness.Right); + } + + private void SetStatus(string message) + { + TxtStatusMessage.Text = message; + AutomationProperties.SetName(StatusBorder, message); + StatusBorder.Visibility = string.IsNullOrWhiteSpace(message) ? Visibility.Collapsed : Visibility.Visible; + if (!string.IsNullOrWhiteSpace(message)) + { + _inlineInfoStatus = string.Empty; + TxtModelState.Visibility = Visibility.Collapsed; + } + } + + private void SetInfoStatus(string message) + { + _inlineInfoStatus = message ?? string.Empty; + if (IsInitialized) + { + RefreshUiState(); + } + } + + private void ClearInfoStatus() => SetInfoStatus(string.Empty); + + private void RenderDiff() + { + var paragraph = new Paragraph { Margin = new Thickness(0) }; + foreach (TextDiffSegment segment in TextDiff.Create(_originalText, _processedText)) + { + var run = new Run(segment.Text); + if (segment.Kind == TextDiffKind.Added) + { + run.Foreground = (Brush)FindResource("TextProcessingDiffAddedBrush"); + run.TextDecorations = TextDecorations.Underline; + } + else if (segment.Kind == TextDiffKind.Removed) + { + run.Foreground = (Brush)FindResource("TextProcessingDiffRemovedBrush"); + run.TextDecorations = TextDecorations.Strikethrough; + } + paragraph.Inlines.Add(run); + } + DiffViewer.Document = new FlowDocument(paragraph) + { + PagePadding = new Thickness(0), + FontFamily = TxtEditor.FontFamily, + FontSize = TxtEditor.FontSize, + Foreground = TxtEditor.Foreground + }; + } + + private void ApplyModeToUi() + { + ModeProofread.IsSelected = _currentMode == TextProcessingMode.Proofread; + ModeTypography.IsSelected = _currentMode == TextProcessingMode.Typography; + ModeCleanup.IsSelected = _currentMode == TextProcessingMode.Cleanup; + TxtModeDescription.Text = _currentMode switch + { + TextProcessingMode.Proofread => LocalizationService.Get("TextProcessing_ModeProofreadDesc"), + TextProcessingMode.Typography => LocalizationService.Get("TextProcessing_ModeTypographyDesc"), + TextProcessingMode.Cleanup => LocalizationService.Get("TextProcessing_ModeCleanupDesc"), + _ => string.Empty + }; + } + + protected override void OnLocalizationChanged() + { + base.OnLocalizationChanged(); + ApplyModeToUi(); + RefreshUiState(); + UpdateCommandButtonLayout(); + } + + private void RefreshClipboardAvailability(bool showError) + { + try + { + _hasClipboardText = Clipboard.ContainsText(); + } + catch (Exception ex) + { + _hasClipboardText = false; + Logger.Log(ex); + if (showError) + { + SetStatus(LocalizationService.Get("TextProcessing_ErrorClipboard")); + } + } + } + + private void FocusEditor() + { + Dispatcher.BeginInvoke(new Action(() => + { + if (TxtEditor.IsEnabled) + { + TxtEditor.Focus(); + Keyboard.Focus(TxtEditor); + } + })); + } + + private async Task LoadModelsAsync(CancellationToken cancellationToken) + { + _isLoadingModels = true; + _hasEligibleModel = false; + _models.Clear(); + string automaticLabel = LocalizationService.Get("TextProcessing_ModelAuto"); + _models.Add(new ModelItem(null, null, automaticLabel, null) { FullDisplay = automaticLabel }); + CmbModels.SelectedIndex = 0; + RefreshUiState(); + var availableModels = new List(); + var preferredIdentities = new HashSet(StringComparer.OrdinalIgnoreCase); + AiSettings? aiSettings = _settingsService.Settings.Ai; + if (aiSettings?.Connections != null) + { + foreach (AiConnectionSettings connection in aiSettings.Connections.Where(c => c.IsEnabled)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + IReadOnlyList models = await _gateway.GetModelsAsync(connection, cancellationToken); + foreach (AiModelDescriptor model in models.Where(IsEligibleModel)) + { + availableModels.Add(model); + if (string.Equals( + model.ModelId, + connection.PreferredModelId, + StringComparison.OrdinalIgnoreCase)) + { + preferredIdentities.Add(CreateModelIdentity(model.ProviderId, model.ModelId)); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.Log(ex); + } + } + } + IReadOnlyList logicalModels = BuildLogicalModelItems(availableModels, preferredIdentities); + foreach (ModelItem model in logicalModels) + { + _models.Add(model); + } + _hasEligibleModel = logicalModels.Count > 0; + RestoreModelSelection(); + _isLoadingModels = false; + RefreshUiState(); + } + + internal static bool IsEligibleModel(AiModelDescriptor model) => + HasVisibleModelText(model.ModelId) && + !model.IsDeprecated && + (model.Capabilities & AiCapabilities.Text) == AiCapabilities.Text && + (model.CostStatus is AiCostStatus.VerifiedFree or AiCostStatus.FreeTierAvailable) && + TextProcessingService.IsSuitableForWritingModel(model); + + private static bool HasVisibleModelText(string? value) => + !string.IsNullOrEmpty(value) && value.Any(character => + !char.IsWhiteSpace(character) && + !char.IsControl(character) && + CharUnicodeInfo.GetUnicodeCategory(character) != UnicodeCategory.Format); + + private static string NormalizeModelText(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + return new string(value.Where(character => + !char.IsControl(character) && + CharUnicodeInfo.GetUnicodeCategory(character) != UnicodeCategory.Format).ToArray()).Trim(); + } + + internal static IReadOnlyList BuildLogicalModelItems( + IEnumerable models, + IReadOnlySet? preferredIdentities = null) + { + ArgumentNullException.ThrowIfNull(models); + var grouped = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (AiModelDescriptor model in models.Where(IsEligibleModel)) + { + string identity = CreateModelIdentity(model.ProviderId, model.ModelId); + if (!grouped.TryGetValue(identity, out List? routes)) + { + routes = []; + grouped.Add(identity, routes); + } + routes.Add(model); + } + + var items = grouped.Select(pair => + { + AiModelDescriptor first = pair.Value[0]; + string display = pair.Value + .Select(model => NormalizeModelText(model.DisplayName)) + .FirstOrDefault(HasVisibleModelText) ?? NormalizeModelText(first.ModelId); + int? contextLength = pair.Value.Any(model => !model.ContextLength.HasValue) + ? null + : pair.Value.Max(model => model.ContextLength); + return new + { + Identity = pair.Key, + Item = new ModelItem(first.ProviderId, first.ModelId, display, contextLength) + }; + }).ToList(); + + var duplicateDisplays = items + .GroupBy(entry => entry.Item.Display, StringComparer.CurrentCultureIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.CurrentCultureIgnoreCase); + + return items + .Select(entry => + { + string fullDisplay = duplicateDisplays.Contains(entry.Item.Display) + ? $"{entry.Item.Display} — {GetProviderDisplayName(entry.Item.ProviderId)}" + : entry.Item.Display; + return new + { + entry.Identity, + IsPreferred = preferredIdentities?.Contains(entry.Identity) == true, + Item = entry.Item with { FullDisplay = fullDisplay } + }; + }) + .OrderByDescending(entry => entry.IsPreferred) + .ThenBy(entry => entry.Item.FullDisplay, StringComparer.CurrentCultureIgnoreCase) + .Select(entry => entry.Item) + .ToArray(); + } + + private static string CreateModelIdentity(string? providerId, string? modelId) => + $"{providerId?.Trim()}\n{modelId?.Trim()}"; + + private static string GetProviderDisplayName(string? providerId) => + providerId != null && AiProviderCatalog.TryGet(providerId, out AiProviderDefinition definition) + ? definition.DisplayName + : NormalizeModelText(providerId); + + private ModelItem? FindModel(string? providerId, string? modelId) => + _models.FirstOrDefault(m => + string.Equals(m.ProviderId, providerId, StringComparison.OrdinalIgnoreCase) && + string.Equals(m.ModelId, modelId, StringComparison.OrdinalIgnoreCase)); + + private bool TrySelectModel(string? providerId, string? modelId) + { + ModelItem? model = FindModel(providerId, modelId); + if (model == null) + { + return false; + } + CmbModels.SelectedItem = model; + _selectedProviderId = model.ProviderId; + _selectedModelId = model.ModelId; + return true; + } + + private static TextProcessingMode ParseSavedMode(int value) => + Enum.IsDefined(typeof(TextProcessingMode), value) + ? (TextProcessingMode)value + : TextProcessingMode.Proofread; + + private void SaveModeSelection() + { + _settingsService.UpdateSettings(settings => + settings.TextProcessingLastMode = (int)_currentMode); + } + + private void RestoreModelSelection() + { + AppSettings settings = _settingsService.Settings; + bool hadLegacyConnectionSelection = + !string.IsNullOrWhiteSpace(settings.TextProcessingSelectedConnectionId); + _isAutoModel = settings.TextProcessingIsAutoModel; + bool replacedUnavailableSelection = false; + if (!_isAutoModel) + { + ModelItem? model = FindModel( + settings.TextProcessingSelectedProviderId, + settings.TextProcessingSelectedModelId); + if (model != null) + { + CmbModels.SelectedItem = model; + _selectedProviderId = model.ProviderId; + _selectedModelId = model.ModelId; + SaveModelSelection(); + return; + } + _isAutoModel = true; + replacedUnavailableSelection = true; + } + CmbModels.SelectedIndex = 0; + _selectedProviderId = null; + _selectedModelId = null; + if (replacedUnavailableSelection || hadLegacyConnectionSelection) + { + SaveModelSelection(); + } + if (replacedUnavailableSelection) + { + SetStatus(LocalizationService.Get("TextProcessing_ModelUnavailable")); + } + } + + private void SaveModelSelection() + { + _settingsService.UpdateSettings(settings => + { + settings.TextProcessingIsAutoModel = _isAutoModel; + settings.TextProcessingSelectedConnectionId = null; + settings.TextProcessingSelectedProviderId = _isAutoModel ? null : _selectedProviderId; + settings.TextProcessingSelectedModelId = _isAutoModel ? null : _selectedModelId; + }); + } + + private void RestoreWindowState(AppSettings settings) + { + Forms.Screen screen = GetTargetScreen(settings.TextProcessingLeft, settings.TextProcessingTop); + System.Drawing.Rectangle work = screen.WorkingArea; + double maxWidth = Math.Max(640, work.Width * WorkAreaRatio); + double maxHeight = Math.Max(560, work.Height * WorkAreaRatio); + MinWidth = Math.Min(_requiredMinWidth, maxWidth); + MinHeight = Math.Min(PreferredMinHeight, maxHeight); + Width = Math.Clamp(settings.TextProcessingWidth ?? PreferredWidth, MinWidth, Math.Min(MaxWidth, maxWidth)); + Height = Math.Clamp(settings.TextProcessingHeight ?? PreferredHeight, MinHeight, Math.Min(MaxHeight, maxHeight)); + double desiredLeft = settings.TextProcessingLeft ?? work.Left + (work.Width - Width) / 2; + double desiredTop = settings.TextProcessingTop ?? work.Top + (work.Height - Height) / 2; + Left = Math.Clamp(desiredLeft, work.Left, Math.Max(work.Left, work.Right - Width)); + Top = Math.Clamp(desiredTop, work.Top, Math.Max(work.Top, work.Bottom - Height)); + WindowStartupLocation = WindowStartupLocation.Manual; + WindowState = string.Equals(settings.TextProcessingWindowState, "Maximized", StringComparison.Ordinal) + ? WindowState.Maximized + : WindowState.Normal; + } + + private static Forms.Screen GetTargetScreen(double? left, double? top) + { + if (left.HasValue && top.HasValue && double.IsFinite(left.Value) && double.IsFinite(top.Value)) + { + return Forms.Screen.FromPoint(new System.Drawing.Point((int)left.Value, (int)top.Value)); + } + return Forms.Screen.PrimaryScreen ?? throw new InvalidOperationException("No Windows display is available."); + } + + private void SaveWindowState() + { + if (_isLoadingState || WindowState == WindowState.Minimized) + { + return; + } + Rect bounds = WindowState == WindowState.Normal + ? new Rect(Left, Top, ActualWidth, ActualHeight) + : RestoreBounds; + if (bounds.IsEmpty || !double.IsFinite(bounds.Width) || !double.IsFinite(bounds.Height)) + { + return; + } + string state = WindowState == WindowState.Maximized ? "Maximized" : "Normal"; + _settingsService.UpdateSettings(settings => + { + settings.TextProcessingLeft = bounds.Left; + settings.TextProcessingTop = bounds.Top; + settings.TextProcessingWidth = bounds.Width; + settings.TextProcessingHeight = bounds.Height; + settings.TextProcessingWindowState = state; + }); + } +} + +public sealed record ModelItem( + string? ProviderId, + string? ModelId, + string Display, + int? ContextLength) +{ + public string FullDisplay { get; init; } = Display; +} diff --git a/AiteBar/TimerStopwatchWindow.xaml b/AiteBar/TimerStopwatchWindow.xaml index 06718cf..d8ce052 100644 --- a/AiteBar/TimerStopwatchWindow.xaml +++ b/AiteBar/TimerStopwatchWindow.xaml @@ -13,43 +13,15 @@ -