From caa78b70d4724c260f3f7f4000fd430d414d878f Mon Sep 17 00:00:00 2001 From: Codebdbd Date: Sat, 18 Jul 2026 14:04:30 +0300 Subject: [PATCH 01/19] Modernize settings and add AI provider gateway --- AI_PROVIDERS_EXECPLAN.md | 213 +++ AiteBar.Tests/AiProviderTests.cs | 238 ++++ .../AppSettingsDiscreteChoiceHelperTests.cs | 95 ++ .../AppSettingsLayoutContractTests.cs | 376 +++++ ...AppSettingsSectionNavigationHelperTests.cs | 71 + AiteBar.Tests/AppSettingsServiceTests.cs | 8 + .../AppSettingsWindowIntegrationTests.cs | 87 +- AiteBar.Tests/HotkeyCaptureHelperTests.cs | 110 ++ AiteBar.Tests/HotkeyServiceTests.cs | 8 +- AiteBar.Tests/IndicatorPositionHelperTests.cs | 50 + .../RuntimeLocalizationWindowSourceTests.cs | 8 +- AiteBar.Tests/SupportLinksTests.cs | 8 +- AiteBar/AboutWindow.xaml | 122 -- AiteBar/AboutWindow.xaml.cs | 99 -- AiteBar/AiConnectionDialog.xaml | 77 + AiteBar/AiConnectionDialog.xaml.cs | 81 ++ AiteBar/AiCredentialStore.cs | 165 +++ AiteBar/AiGateway.cs | 264 ++++ AiteBar/AiModels.cs | 117 ++ AiteBar/AiProviderCatalog.cs | 175 +++ AiteBar/AiProviderClient.cs | 544 ++++++++ AiteBar/AiteBar.csproj | 1 + AiteBar/AppSettingsDiscreteChoiceHelper.cs | 41 + AiteBar/AppSettingsSectionNavigationHelper.cs | 64 + AiteBar/AppSettingsService.cs | 41 + AiteBar/AppSettingsWindow.xaml | 1233 ++++++++++++----- AiteBar/AppSettingsWindow.xaml.cs | 1007 +++++++++++--- AiteBar/HotkeyCaptureBox.cs | 161 +++ AiteBar/HotkeyCaptureHelper.cs | 70 + AiteBar/HotkeyService.cs | 14 +- AiteBar/IndicatorPositionHelper.cs | 43 + AiteBar/MainWindow.TrayMenuHandler.cs | 4 +- AiteBar/MainWindow.xaml.cs | 55 +- AiteBar/Models.cs | 5 + AiteBar/NativeMethods.cs | 3 + AiteBar/Resources/Strings.de.resx | 81 +- AiteBar/Resources/Strings.resx | 81 +- AiteBar/Resources/Strings.ru.resx | 83 +- AiteBar/Resources/Strings.uk.resx | 81 +- AiteBar/TaskbarGeometryHelper.cs | 22 + AiteBar/TaskbarPositionIndicatorService.cs | 156 ++- AiteBar/TaskbarPositionIndicatorWindow.xaml | 3 +- .../TaskbarPositionIndicatorWindow.xaml.cs | 90 +- PRIVACY.md | 10 + README.md | 9 +- docs/AI_PROVIDERS.md | 124 ++ docs/DESIGN.md | 14 +- docs/USER_MANUAL.md | 14 +- docs/about-settings-integration-execplan.md | 129 ++ docs/app-settings-modern-controls-execplan.md | 229 +++ docs/app-settings-sidebar-execplan.md | 219 +++ docs/architecture.md | 16 +- docs/creating-ai-utilities.md | 847 +++++++++++ docs/functions.md | 25 +- docs/taskbar-indicator-position-execplan.md | 80 ++ docs/technical-reference.md | 4 + 56 files changed, 7189 insertions(+), 786 deletions(-) create mode 100644 AI_PROVIDERS_EXECPLAN.md create mode 100644 AiteBar.Tests/AiProviderTests.cs create mode 100644 AiteBar.Tests/AppSettingsDiscreteChoiceHelperTests.cs create mode 100644 AiteBar.Tests/AppSettingsLayoutContractTests.cs create mode 100644 AiteBar.Tests/AppSettingsSectionNavigationHelperTests.cs create mode 100644 AiteBar.Tests/HotkeyCaptureHelperTests.cs create mode 100644 AiteBar.Tests/IndicatorPositionHelperTests.cs delete mode 100644 AiteBar/AboutWindow.xaml delete mode 100644 AiteBar/AboutWindow.xaml.cs create mode 100644 AiteBar/AiConnectionDialog.xaml create mode 100644 AiteBar/AiConnectionDialog.xaml.cs create mode 100644 AiteBar/AiCredentialStore.cs create mode 100644 AiteBar/AiGateway.cs create mode 100644 AiteBar/AiModels.cs create mode 100644 AiteBar/AiProviderCatalog.cs create mode 100644 AiteBar/AiProviderClient.cs create mode 100644 AiteBar/AppSettingsDiscreteChoiceHelper.cs create mode 100644 AiteBar/AppSettingsSectionNavigationHelper.cs create mode 100644 AiteBar/HotkeyCaptureBox.cs create mode 100644 AiteBar/HotkeyCaptureHelper.cs create mode 100644 AiteBar/IndicatorPositionHelper.cs create mode 100644 docs/AI_PROVIDERS.md create mode 100644 docs/about-settings-integration-execplan.md create mode 100644 docs/app-settings-modern-controls-execplan.md create mode 100644 docs/app-settings-sidebar-execplan.md create mode 100644 docs/creating-ai-utilities.md create mode 100644 docs/taskbar-indicator-position-execplan.md diff --git a/AI_PROVIDERS_EXECPLAN.md b/AI_PROVIDERS_EXECPLAN.md new file mode 100644 index 0000000..05522c5 --- /dev/null +++ b/AI_PROVIDERS_EXECPLAN.md @@ -0,0 +1,213 @@ +# Реализовать безопасный шлюз бесплатных AI-провайдеров + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Этот документ ведётся в соответствии с `PLANS.md` в корне репозитория. Он должен оставаться самодостаточным: разработчику достаточно текущего рабочего дерева и этого файла, чтобы продолжить реализацию без истории переписки. + +## Purpose / Big Picture + +После изменения пользователь сможет открыть раздел «Нейросети» в настройках AiteBar, добавить несколько API-подключений OpenRouter, Cerebras, Google Gemini, Groq, GitHub Models или Mistral, безопасно сохранить ключи средствами Windows и проверить каждое подключение. Подключения одного провайдера будут иметь приоритет и обозначение общей квоты, чтобы будущие утилиты могли переключиться на следующий независимый аккаунт или проект после исчерпания лимита, а затем перейти к следующему провайдеру. Платный fallback не включается: первый выпуск предназначен только для бесплатных тарифов и никогда не должен незаметно создавать расходы. + +Работу можно увидеть без создания отдельной AI-утилиты: в настройках появляется новый раздел со списком подключений, кнопками добавления, удаления и проверки; ключ отсутствует в `settings.json`; проверка загружает каталог моделей и показывает количество доступных моделей либо понятную ошибку. Автоматический шлюз покрывается тестами с поддельными HTTP-ответами: при `429` он помечает общую квоту временно недоступной и выбирает следующее подключение. + +## Progress + +- [x] (2026-07-18 04:04Z) Изучены `PLANS.md`, текущее хранение настроек, устройство `AppSettingsWindow`, реестр утилит и локализация. +- [x] (2026-07-18 04:04Z) Проверены официальные API и правила квот OpenRouter, Cerebras, Gemini, Groq, GitHub Models и Mistral; DeepInfra исключён, потому что постоянная бесплатная квота не подтверждена. +- [x] (2026-07-18 04:04Z) Создан этот ExecPlan в корне проекта. +- [x] (2026-07-18 04:34Z) Реализованы модели данных, каталог провайдеров и сохранение несекретной конфигурации в `AppSettings`. +- [x] (2026-07-18 04:34Z) Реализованы Windows Credential Manager и тестовая абстракция хранилища секретов. +- [x] (2026-07-18 04:34Z) Реализованы получение моделей, проверка ключей, общий transport и маршрутизация с quota scope и cooldown. +- [x] (2026-07-18 04:34Z) Добавлены диалог подключения и раздел «Нейросети» в `AppSettingsWindow`. +- [x] (2026-07-18 04:34Z) Добавлена английская, русская, украинская и немецкая локализация. +- [x] (2026-07-18 04:34Z) Создан `docs/AI_PROVIDERS.md` и обновлены руководство, privacy, архитектура, карта функций и README. +- [x] (2026-07-18 07:30Z) Добавлены focused unit/integration tests; полный прогон завершён результатом 789/789, production Release опубликован, smoke-запуск успешен и создан `artifacts/installer/AiteBar-Setup.exe`. +- [x] (2026-07-18 08:00Z) Диалог подключения дополнен локализованной ссылкой на официальную страницу получения ключа выбранного сервиса; кнопки приоритета сделаны квадратными 32×32 и корректно отключаются на границах списка. Полный прогон: 790/790, installer пересобран. + +## Surprises & Discoveries + +- Observation: несколько ключей обычно не означают несколько квот. + Evidence: Gemini применяет квоту к Google project, Cerebras проверяет project и organization, Groq имеет organization ceiling, OpenRouter прямо сообщает, что дополнительные ключи или аккаунты не увеличивают глобальные free-model limits. Поэтому конфигурация должна хранить `QuotaScopeId`, а cooldown должен применяться к группе, не только к ключу. + +- Observation: существующий `AppSettingsService` сериализует весь `AppSettings` в обычный JSON. + Evidence: `AiteBar/AppSettingsService.cs` использует `System.Text.Json`; следовательно, API-ключ нельзя добавлять в `Models.cs` как строковое свойство настроек. + +- Observation: `AppSettingsWindow` использует единый прокручиваемый документ и синхронизирует индекс меню с массивом секций. + Evidence: `GetSettingsSections()` и `SettingsNavigationList` имеют одинаковый порядок. Новый пункт должен быть добавлен перед `About` одновременно в XAML, массив и `AppSettingsSection`, иначе навигация будет вести не туда. + +- Observation: sandbox не разрешает MSBuild атомарно заменить файлы в существующих `obj/bin`, а полностью изолированный build сначала не имел NuGet assets. + Evidence: обычная команда получила `MSB3491 access denied`; после разрешённого restore production-проект собрался в `AiteBar.dll`, а test project потребовал отдельной проверки выходных путей. + +- Observation: полный `dotnet test` успешно компилирует и запускает тестовый проект, однако отдельный повторный `dotnet build` решения в этой сессии не может атомарно обновить служебные `Microsoft.CodeCoverage`/`CoverletSourceRootsMapping` файлы из-за ACL временных файлов Windows. + Evidence: 789 тестов прошли из `artifacts/ai-test-output`, production `AiteBar.dll` собирается, штатный installer script успешно выполнил restore, Release publish и Inno Setup; сбой воспроизводится только в targets coverage тестового проекта и не относится к исходному коду. + +## Decision Log + +- Decision: первый выпуск поддерживает OpenRouter, Cerebras, Gemini, Groq, GitHub Models и Mistral; DeepInfra не включается. + Rationale: у первых шести есть документированный бесплатный режим или включённая бесплатная квота, а у DeepInfra обнаружена только pay-as-you-go модель. + Date/Author: 2026-07-18 / Codex. + +- Decision: секреты сохраняются в Windows Credential Manager под именами `AiteBar/AI/{connection-guid}`, а `settings.json` содержит только ссылку на credential target. + Rationale: проект Windows-only, Credential Manager предназначен для пользовательских секретов и позволяет удалить отдельный ключ. Это предотвращает попадание ключей в настройки, резервные JSON-файлы и диагностику. + Date/Author: 2026-07-18 / Codex. + +- Decision: использовать собственные небольшие REST-клиенты поверх `HttpClient`, без шести SDK. + Rationale: OpenRouter, Cerebras, Groq, GitHub Models и Mistral имеют OpenAI-подобные интерфейсы, а Gemini требует только отдельного REST-адаптера. Это уменьшает размер publish, поверхность обновлений и риск конфликтов зависимостей. + Date/Author: 2026-07-18 / Codex. + +- Decision: подключения выбираются по приоритету, не round-robin; quota scope отключается целиком при подтверждённом исчерпании общей квоты. + Rationale: пользователь хочет сначала расходовать основное бесплатное подключение и переходить к резервному. Round-robin затрудняет понимание остатков и способен одновременно исчерпать все резервы. + Date/Author: 2026-07-18 / Codex. + +- Decision: первая UI-версия проверяет ключ через безопасный запрос списка моделей и не отправляет тестовый prompt. + Rationale: каталог не расходует inference-токены, не передаёт пользовательские данные и достаточен для проверки авторизации у поддерживаемых провайдеров. + Date/Author: 2026-07-18 / Codex. + +- Decision: режим первого выпуска всегда `FreeTierOnly`; автоматического перехода к платному inference нет. + Rationale: цель функции — использование бесплатных квот. Невидимый платный fallback является финансовым риском и требует отдельного осознанного продукта с бюджетами и подтверждением пользователя. + Date/Author: 2026-07-18 / Codex. + +## Outcomes & Retrospective + +Milestone завершён: существует безопасный credential store, динамические каталоги шести провайдеров, общий chat transport, quota-aware gateway, UI нескольких подключений и пользовательская документация. Полный набор из 789 тестов прошёл без падений. Штатный скрипт создал `artifacts/installer/AiteBar-Setup.exe` размером 77 521 960 байт с SHA-256 `F60F0F8BB63A82D36F23303CE992CBC2B865189A4BDF9E850E6FF0F92EFEA86C`; опубликованное приложение успешно прошло краткий startup smoke test. + +Ручная проверка с настоящими API-ключами намеренно не выполнялась: ключи принадлежат пользователю, а автоматическая проверка не должна обращаться к внешним inference API. UI-контракты, lifecycle credential metadata, fallback и локализация проверены автоматическими тестами. + +## Context and Orientation + +`AiteBar` — WPF-приложение .NET 10 для Windows. `AiteBar/Models.cs` содержит сериализуемый `AppSettings`. `AiteBar/AppSettingsService.cs` клонирует настройки, загружает и атомарно сохраняет JSON. `AiteBar/AppSettingsWindow.xaml` и `.xaml.cs` образуют единое окно настроек с меню слева и прокручиваемыми секциями справа. `AiteBar/PathHelper.cs` определяет `%APPDATA%\Codebdbd\Aite Bar`. `AiteBar/UtilityRegistry.cs` запускает встроенные утилиты; будущие AI-утилиты будут получать общий шлюз, а не создавать собственные HTTP-клиенты. + +В этом плане «провайдер» означает внешний AI-сервис, например Cerebras. «Подключение» означает один сохранённый API-ключ вместе с понятным названием и принадлежностью к аккаунту или проекту. «Quota scope» означает группу подключений, которые делят одну внешнюю квоту. «Cooldown» означает время, до которого подключение или quota scope временно не выбирается после `429 Too Many Requests`. «Gateway» означает единый C#-сервис, через который будущая утилита отправляет запрос, не зная деталей авторизации и fallback. + +Новая конфигурация в `AppSettings` хранит список `AiConnectionSettings`, порядок провайдеров и флаг `FreeTierOnly`. `AiConnectionSettings` не содержит ключ: только GUID, provider ID, название, credential target, quota scope, приоритет, enabled и необязательную предпочитаемую модель. `AppSettingsService.CloneAppSettings` обязан глубоко копировать эти значения. + +`IAiCredentialStore` скрывает Windows API и имеет операции write/read/delete. Production-реализация `WindowsCredentialStore` вызывает Unicode-функции `CredWriteW`, `CredReadW`, `CredDeleteW` и всегда освобождает буфер через `CredFree`. Тесты используют память и никогда не записывают настоящие учётные данные. + +`AiProviderCatalog` содержит стабильные определения провайдеров, URL документации и стратегию каталога. Динамический список моделей получают с сервера. `AiProviderClient` нормализует разные JSON-форматы в `AiModelDescriptor`. `AiGateway` получает снимок настроек, сортирует enabled-подключения по порядку провайдеров и приоритету, исключает cooldown и отсутствующие секреты, затем выполняет запрос. Для этого выпуска важно реализовать текстовый chat request, даже если UI-утилита ещё не вызывает его: это доказывает, что слой пригоден для будущего использования. + +## Plan of Work + +Сначала добавить в `AiteBar/AiModels.cs` неизменяемые runtime-типы и сериализуемые настройки. В `Models.cs` добавить `AiSettings` и обеспечить глубокое клонирование в `AppSettingsService.cs`. В `PathHelper` секретные пути не добавлять: секреты не должны храниться файлами. + +Затем создать `AiteBar/AiCredentialStore.cs`. Интерфейс должен быть пригоден для подмены в тестах. Production-класс ограничивает размер ключа, не логирует его и возвращает `null`, если target отсутствует. Ошибки Windows превращаются в `InvalidOperationException` с кодом Win32, но без содержимого ключа. + +После этого создать `AiteBar/AiProviderCatalog.cs`, `AiteBar/AiProviderClient.cs` и `AiteBar/AiGateway.cs`. Каталог задаёт endpoint и тип авторизации. Клиент получает модели, валидирует соединение и выполняет chat completion. Gemini получает отдельные JSON request/response ветки, остальные используют OpenAI-compatible messages. Парсеры должны терпеть неизвестные поля. HTTP timeout равен 30 секундам; cancellation token проходит до `SendAsync` и чтения JSON. + +Маршрутизатор хранит runtime health отдельно от `settings.json`: статус, cooldown и последнее безопасное описание ошибки. `401` отключает конкретный ключ до следующей проверки, `429` уважает `Retry-After` и применяет cooldown к quota scope, `402` помечает scope как exhausted, `5xx` разрешает переход к следующему подключению. В лог не попадают prompt, response и Authorization. + +Для UI создать `AiteBar/AiConnectionDialog.xaml` и `.xaml.cs` с ComboBox провайдера, названием подключения, quota scope и `PasswordBox` для ключа. В `AppSettingsWindow` добавить пункт «Нейросети» перед «О программе», section card, общий privacy-текст, кнопку добавления и динамические строки. Добавление пишет секрет временно и запоминает target; Cancel или закрытие окна удаляет новые секреты, Save фиксирует metadata и удаляет секреты удалённых старых подключений. Проверка получает модели без inference-запроса и показывает результат. Ключ после сохранения никогда не выводится обратно. + +Добавить строки во все четыре `Resources/Strings*.resx`. Создать `docs/AI_PROVIDERS.md` с объяснением безопасности, free-tier ограничений, quota scope, пошаговым получением ключей для шести сервисов, добавлением нескольких подключений, диагностикой `401/403/429`, удалением и предупреждением о передаче данных внешнему сервису. Обновить `docs/USER_MANUAL.md`, `docs/architecture.md`, `docs/functions.md`, `PRIVACY.md` и README короткими ссылками; не обещать фиксированные квоты, потому что провайдеры меняют их. + +Завершить focused tests для каталога, клонирования, credential abstraction, JSON parser, provider ordering, quota-scope cooldown и XAML-контракта. Все внешние ответы должны быть поддельными через `HttpMessageHandler`; тесты не требуют сети или настоящих ключей. + +## Concrete Steps + +Рабочая директория для всех команд: `D:\01_Codebdbd\01_projects\aitebar`. + +Проверить focused tests после создания ядра: + + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release --filter "FullyQualifiedName~Ai" + +Ожидается ноль падений; число тестов будет зафиксировано после добавления. + +Проверить полный проект: + + dotnet build .\AiteBar.sln -c Release + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release + +Если `dotnet test` падает только из-за WPF `wpftmp` или generated `obj`, использовать уже собранную DLL: + + dotnet vstest .\AiteBar.Tests\bin\Release\net10.0-windows\AiteBar.Tests.dll + +После UI-изменения вручную открыть «Настройки программы → Нейросети», добавить тестовое подключение, закрыть через Cancel и убедиться, что временный credential удалён; затем добавить снова, сохранить, открыть настройки и увидеть metadata без ключа. Кнопка «Проверить» с настоящим ключом должна показать число моделей. Этот сетевой smoke test выполняется только пользователем с собственным ключом; автоматические тесты сети не вызывают. + +Собрать поставку: + + .\installer\Build-Installer.ps1 + +Ожидается один актуальный installer в `artifacts\installer`. + +## Validation and Acceptance + +При запуске Release окно настроек содержит шесть навигационных пунктов, включая «Нейросети» перед «О программе». Раздел не выходит за `MaxWidth` правой колонки и использует существующие Windows 11-подобные cards без рамки вокруг каждой мелкой строки. + +Пользователь может добавить два подключения одного провайдера с разными названиями и quota scope, изменить их приоритет кнопками или порядком строк, проверить каждое и удалить. После Save и перезапуска metadata остаётся. В `%APPDATA%\Codebdbd\Aite Bar\settings.json` нет API-ключа и его фрагментов. Удаление подключения удаляет credential. + +Focused-тест маршрутизатора создаёт три подключения: первое возвращает `429`, второе имеет тот же quota scope, третье другой scope. Gateway не вызывает второе и успешно использует третье. Другой тест доказывает переход к следующему провайдеру. Тест `401` доказывает, что недействителен только ключ, а не вся независимая quota group. + +Каталоги шести провайдеров нормализуются в непустые model IDs из fixture JSON. Бесплатный режим никогда не выбирает модель, явно помеченную `Paid`; неизвестная стоимость не превращается в `VerifiedFree`. + +Полный Release build и все существующие тесты проходят. Installer создаётся. Документация позволяет новому пользователю получить ключ каждого сервиса, подключить его и понять ограничения без обращения к исходному коду. + +## Idempotence and Recovery + +Добавление и удаление metadata выполняется через существующую атомарную запись настроек. Credential target строится из GUID, поэтому повторное сохранение обновляет ту же запись без дубликатов. При Cancel новые targets удаляются. Если приложение аварийно завершится между записью нового credential и Save, останется недоступный из UI credential без metadata; при следующем развитии можно добавить garbage collection по префиксу, но первый выпуск не должен перечислять и удалять чужие targets автоматически. + +Нельзя очищать рабочее дерево или откатывать существующие незакоммиченные изменения: они принадлежат пользователю. При конфликте редактировать только соответствующие блоки. Настоящие ключи не использовать в тестах, патчах, логах, screenshots или документации. + +## Artifacts and Notes + +Подтверждённые endpoint-ы, необходимые для реализации: + + OpenRouter catalog: https://openrouter.ai/api/v1/models + OpenRouter chat: https://openrouter.ai/api/v1/chat/completions + Cerebras models: https://api.cerebras.ai/v1/models + Cerebras chat: https://api.cerebras.ai/v1/chat/completions + Gemini models: https://generativelanguage.googleapis.com/v1beta/models?key=... + Gemini generate: https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key=... + Groq models: https://api.groq.com/openai/v1/models + Groq chat: https://api.groq.com/openai/v1/chat/completions + GitHub catalog: https://models.github.ai/catalog/models + GitHub inference: https://models.github.ai/inference/chat/completions + Mistral models: https://api.mistral.ai/v1/models + Mistral chat: https://api.mistral.ai/v1/chat/completions + +Короткое доказательство текущего риска хранения ключа: + + AppSettingsService -> JsonSerializer -> settings.json + +Поэтому `AiConnectionSettings` содержит `CredentialTarget`, но никогда `ApiKey`. + +## Interfaces and Dependencies + +В `AiteBar/AiCredentialStore.cs` должны существовать: + + internal interface IAiCredentialStore + { + void Write(string target, string secret); + string? Read(string target); + bool Delete(string target); + } + +В `AiteBar/AiProviderClient.cs` должны существовать методы: + + Task CheckConnectionAsync( + AiConnectionSettings connection, + CancellationToken cancellationToken); + + Task> GetModelsAsync( + AiConnectionSettings connection, + CancellationToken cancellationToken); + + Task GenerateAsync( + AiConnectionSettings connection, + AiModelDescriptor model, + AiChatRequest request, + CancellationToken cancellationToken); + +В `AiteBar/AiGateway.cs` публичный для внутренних утилит контракт: + + Task GenerateAsync( + AiChatRequest request, + CancellationToken cancellationToken = default); + +Новых NuGet-зависимостей для HTTP или секретов не требуется. Используются `System.Net.Http`, `System.Text.Json` и Win32 Credential Management API, доступный на поддерживаемой Windows-платформе. + +Revision note (2026-07-18 04:04Z): создан первоначальный самодостаточный план после исследования репозитория и официальных API; зафиксированы границы бесплатного первого выпуска и безопасная модель нескольких подключений. + +Revision note (2026-07-18 04:34Z): отражена фактическая реализация ядра, UI, четырёх локализаций и документации; добавлена обнаруженная особенность WPF/MSBuild sandbox и уточнён оставшийся этап проверки. + +Revision note (2026-07-18 07:30Z): план закрыт фактическими результатами полного тестового прогона, Release publish, installer build, checksum и startup smoke test; документировано окруженческое ограничение повторной записи coverage path maps. + +Revision note (2026-07-18 08:00Z): отражена UX-доработка диалога — официальный адрес получения API-ключа зависит от выбранного провайдера; зафиксированы квадратные кнопки приоритета и повторная проверка поставки. diff --git a/AiteBar.Tests/AiProviderTests.cs b/AiteBar.Tests/AiProviderTests.cs new file mode 100644 index 0000000..dba1079 --- /dev/null +++ b/AiteBar.Tests/AiProviderTests.cs @@ -0,0 +1,238 @@ +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(["openrouter", "cerebras", "gemini", "groq", "github", "mistral"], ids); + Assert.DoesNotContain("deepinfra", 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", "account-a", 2), + Connection("unsafe", "cerebras", "OtherApp/credential", "account-b", 0), + Connection("unknown", "missing", "AiteBar/AI/unknown", "account-c", 0) + ] + }; + + 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 void OpenRouterParser_OnlyMarksZeroInputAndOutputPricingAsVerifiedFree() + { + AiProviderCatalog.TryGet("openrouter", out AiProviderDefinition provider); + using JsonDocument json = JsonDocument.Parse(""" + {"data":[ + {"id":"openrouter/free","name":"Free router"}, + {"id":"vendor/free","pricing":{"prompt":"0","completion":"0"}}, + {"id":"vendor/paid","pricing":{"prompt":"0","completion":"0.1"}} + ]} + """); + + IReadOnlyList models = AiProviderClient.ParseOpenRouterModels(provider, json.RootElement); + + Assert.Equal(AiCostStatus.VerifiedFree, models.Single(model => model.ModelId == "openrouter/free").CostStatus); + Assert.Equal(AiCostStatus.VerifiedFree, models.Single(model => model.ModelId == "vendor/free").CostStatus); + Assert.Equal(AiCostStatus.Paid, models.Single(model => model.ModelId == "vendor/paid").CostStatus); + } + + [Fact] + public async Task Gateway_SkipsAnotherKeyInSameQuotaScopeAfterRateLimit() + { + var credentials = new MemoryCredentialStore(); + credentials.Write("AiteBar/AI/one", "key-one"); + credentials.Write("AiteBar/AI/two", "key-two"); + credentials.Write("AiteBar/AI/three", "key-three"); + + 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 = ["openrouter"], + Connections = + [ + Connection("one", "openrouter", "AiteBar/AI/one", "shared", 0), + Connection("two", "openrouter", "AiteBar/AI/two", "shared", 1), + Connection("three", "openrouter", "AiteBar/AI/three", "independent", 2) + ] + } + }; + var gateway = new AiGateway(settingsService, client, TimeProvider.System); + + AiGatewayResponse response = await gateway.GenerateAsync(new AiChatRequest + { + Messages = [new AiChatMessage("user", "hello")] + }); + + Assert.Equal("ok", response.Content); + Assert.Equal("three", response.ConnectionId); + Assert.Contains("key-one", handler.SeenKeys); + Assert.DoesNotContain("key-two", handler.SeenKeys); + Assert.Contains("key-three", handler.SeenKeys); + Assert.Equal(AiConnectionState.CoolingDown, + gateway.GetQuotaStatus(settingsService.Settings.Ai.Connections[0])?.State); + } + + [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", "personal", 0)] + } + }; + + 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, + string quotaScope, + int priority) => new() + { + Id = id, + ProviderId = providerId, + DisplayName = id, + CredentialTarget = target, + QuotaScopeId = quotaScope, + Priority = priority, + IsEnabled = true + }; + + 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; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + string key = request.Headers.Authorization?.Parameter ?? string.Empty; + SeenKeys.Add(key); + if (request.Method == HttpMethod.Get) + { + return Task.FromResult(Json(HttpStatusCode.OK, + "{\"data\":[{\"id\":\"openrouter/free\",\"name\":\"Free router\"}]}")); + } + 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); + } + if (key == "key-two") + { + throw new Xunit.Sdk.XunitException("A connection in the same quota scope must be skipped."); + } + 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 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/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..7e0684c --- /dev/null +++ b/AiteBar.Tests/AppSettingsLayoutContractTests.cs @@ -0,0 +1,376 @@ +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", + "ChkClipboardManagerPersistHistory", "QuickToolsList", + "QuickToolRowQuickNote", "QuickToolRowQRCodeGenerator", "QuickToolRowDownloads", "QuickToolRowVideo", + "QuickToolRowCopilot", "QuickToolRowCalculator", "QuickToolRowIconConverter", "QuickToolRowClipboardManager", + "QuickToolRowColorPicker", "QuickToolRowSearch", "QuickToolRowShowDesktop", "QuickToolRowAppsFolder", + "QuickToolRowExplorer", "QuickToolRowScreenshot", "QuickToolRowFileSorter", "QuickToolRowTimerStopwatch" + ]; + + [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 window = LoadWindow(); + XElement style = Assert.Single(window.Descendants(PresentationNamespace + "Style"), + element => element.Attribute(XamlNamespace + "Key")?.Value == "ModernSliderStyle"); + + 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(16, 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 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..a6a777f 100644 --- a/AiteBar.Tests/AppSettingsServiceTests.cs +++ b/AiteBar.Tests/AppSettingsServiceTests.cs @@ -993,6 +993,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 +1004,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 = [ @@ -1103,6 +1107,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 +1119,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/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/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/AboutWindow.xaml b/AiteBar/AboutWindow.xaml deleted file mode 100644 index 11eb1ea..0000000 --- a/AiteBar/AboutWindow.xaml +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/AiteBar/TextProcessingWindow.xaml.cs b/AiteBar/TextProcessingWindow.xaml.cs new file mode 100644 index 0000000..1c6bc4a --- /dev/null +++ b/AiteBar/TextProcessingWindow.xaml.cs @@ -0,0 +1,372 @@ +using System; +using System.ComponentModel; +using System.Runtime.Versioning; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace AiteBar; + +[SupportedOSPlatform("windows6.1")] +public partial class TextProcessingWindow : DarkWindow +{ + private readonly TextProcessingViewModel _viewModel; + private readonly AppSettingsService _settingsService; + private bool _isLoadingState; + private bool _isDirty; + + public TextProcessingWindow(TextProcessingViewModel viewModel, AppSettingsService settingsService) + { + _isLoadingState = true; + _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + + InitializeComponent(); + DataContext = _viewModel; + + _viewModel.ShowNotification += OnShowNotification; + _viewModel.ConfirmAction = OnConfirmAction; + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + + BtnProcess.Content = _viewModel.MainButtonText; + TxtModeDescription.Text = _viewModel.ModeDescription; + } + + public void ShowNearPanel(AppSettingsService settingsService) + { + AppSettings settings = settingsService.Settings; + var screens = System.Windows.Forms.Screen.AllScreens; + var screen = settings.MonitorIndex >= 0 && settings.MonitorIndex < screens.Length + ? screens[settings.MonitorIndex] + : System.Windows.Forms.Screen.PrimaryScreen; + var work = screen?.WorkingArea ?? System.Windows.Forms.Screen.PrimaryScreen?.WorkingArea ?? new System.Drawing.Rectangle(0, 0, 1280, 720); + + Width = Math.Min(1280, work.Width * 0.9); + Height = Math.Min(840, work.Height * 0.9); + Left = work.Left + (work.Width - Width) / 2; + Top = work.Top + (work.Height - Height) / 2; + + RestoreWindowState(settings); + Show(); + Activate(); + TxtEditor.Focus(); + } + + internal void RestoreFromAiteBar() + { + WindowState = WindowState.Normal; + if (!IsVisible) + { + Show(); + } + + Activate(); + TxtEditor.Focus(); + } + + private void RestoreWindowState(AppSettings settings) + { + if (settings.TextProcessingWidth.HasValue && settings.TextProcessingHeight.HasValue) + { + double w = settings.TextProcessingWidth.Value; + double h = settings.TextProcessingHeight.Value; + var work = System.Windows.Forms.Screen.PrimaryScreen?.WorkingArea + ?? new System.Drawing.Rectangle(0, 0, 1280, 720); + + if (w >= MinWidth && h >= MinHeight && w <= work.Width * 0.95 && h <= work.Height * 0.95) + { + Width = w; + Height = h; + } + } + + if (settings.TextProcessingLeft.HasValue && settings.TextProcessingTop.HasValue) + { + double l = settings.TextProcessingLeft.Value; + double t = settings.TextProcessingTop.Value; + var work = System.Windows.Forms.Screen.PrimaryScreen?.WorkingArea + ?? new System.Drawing.Rectangle(0, 0, 1280, 720); + + if (l >= work.Left - 100 && l <= work.Right - 100 && + t >= work.Top - 100 && t <= work.Bottom - 100) + { + Left = l; + Top = t; + WindowStartupLocation = WindowStartupLocation.Manual; + } + } + + if (string.Equals(settings.TextProcessingWindowState, "Maximized", StringComparison.Ordinal)) + { + WindowState = WindowState.Maximized; + } + } + + private async void Window_Loaded(object sender, RoutedEventArgs e) + { + _isLoadingState = true; + _viewModel.RestoreMode(); + ApplyModeToUI(); + await _viewModel.LoadModelsAsync(); + _isLoadingState = false; + TxtEditor.Focus(); + } + + private void Window_Closing(object? sender, CancelEventArgs e) + { + if (_isDirty && _viewModel.HasUnsavedContent()) + { + var result = new DarkDialog( + LocalizationService.Get("TextProcessing_ConfirmClose"), + isConfirm: true) { Owner = this }.ShowDialog(); + + if (result != true) + { + e.Cancel = true; + return; + } + } + + SaveWindowState(); + _viewModel.SaveMode(); + } + + private void Window_StateChanged(object? sender, EventArgs e) + { + if (WindowState == WindowState.Minimized) + { + WindowState = WindowState.Normal; + Hide(); + } + } + + private void Window_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (_isLoadingState) return; + SaveWindowState(); + } + + private void Window_LocationChanged(object? sender, EventArgs e) + { + if (_isLoadingState) return; + if (Left > -10000 && Top > -10000) + { + SaveWindowState(); + } + } + + private void Window_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.Control) + { + e.Handled = true; + _ = ProcessAsync(); + } + else if (e.Key == Key.Escape && _viewModel.IsProcessing) + { + e.Handled = true; + _viewModel.CancelProcessing(); + } + } + + private void ModeSegment_Click(object sender, RoutedEventArgs e) + { + if (_isLoadingState || _viewModel == null || TxtModeDescription == null || BtnProcess == null) return; + if (sender is not System.Windows.Controls.RadioButton rb || rb.Tag is not string tag) return; + + switch (tag) + { + case "Proofread": _viewModel.SwitchMode(TextProcessingMode.Proofread); break; + case "Typography": _viewModel.SwitchMode(TextProcessingMode.Typography); break; + case "Cleanup": _viewModel.SwitchMode(TextProcessingMode.Cleanup); break; + } + TxtModeDescription.Text = _viewModel.ModeDescription; + BtnProcess.Content = _viewModel.MainButtonText; + } + + private void TxtEditor_TextChanged(object sender, TextChangedEventArgs e) + { + if (_isLoadingState || _viewModel == null || TxtPlaceholder == null || BtnCopy == null || BtnClear == null) return; + _viewModel.InputText = TxtEditor.Text; + TxtPlaceholder.Visibility = string.IsNullOrEmpty(TxtEditor.Text) + ? Visibility.Visible + : Visibility.Collapsed; + BtnCopy.IsEnabled = !string.IsNullOrEmpty(TxtEditor.Text); + BtnClear.IsEnabled = !string.IsNullOrEmpty(TxtEditor.Text); + _isDirty = true; + } + + private void CmbModels_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoadingState || _viewModel == null || CmbModels == null) return; + if (CmbModels.SelectedItem is ModelItem item) + { + _viewModel.IsAutoModel = item.ModelId == null; + _viewModel.SelectedModelId = item.ModelId; + _viewModel.SelectedProviderId = item.ProviderId; + _viewModel.SelectedModelDisplay = item.Display; + } + } + + private async void BtnProcess_Click(object sender, RoutedEventArgs e) + { + await ProcessAsync(); + } + + private void BtnClear_Click(object sender, RoutedEventArgs e) + { + _viewModel.Clear(); + TxtEditor.Text = string.Empty; + TxtPlaceholder.Visibility = Visibility.Visible; + BtnCopy.IsEnabled = false; + BtnRepeat.IsEnabled = false; + BtnToggleVersion.Visibility = Visibility.Collapsed; + _isDirty = false; + } + + private void BtnPaste_Click(object sender, RoutedEventArgs e) + { + if (Clipboard.ContainsText()) + { + string text = Clipboard.GetText(); + TxtEditor.Text = text; + _viewModel.InputText = text; + TxtPlaceholder.Visibility = Visibility.Collapsed; + BtnCopy.IsEnabled = true; + _isDirty = true; + + _viewModel.OriginalText = string.Empty; + _viewModel.ProcessedText = string.Empty; + _viewModel.HasSuccessfulResult = false; + _viewModel.IsShowingOriginal = false; + BtnRepeat.IsEnabled = false; + BtnToggleVersion.Visibility = Visibility.Collapsed; + } + } + + private void BtnCopy_Click(object sender, RoutedEventArgs e) + { + if (!string.IsNullOrEmpty(TxtEditor.Text)) + { + Clipboard.SetText(TxtEditor.Text); + } + } + + private async void BtnRepeat_Click(object sender, RoutedEventArgs e) + { + await ProcessAsync(); + } + + private void BtnToggleVersion_Click(object sender, RoutedEventArgs e) + { + _viewModel.ToggleVersion(); + TxtEditor.Text = _viewModel.InputText; + TxtPlaceholder.Visibility = string.IsNullOrEmpty(TxtEditor.Text) + ? Visibility.Visible + : Visibility.Collapsed; + TxtCounters.Text = $"{_viewModel.CharacterCountText} · {_viewModel.WordCountText}"; + UpdateToggleVersionLabel(); + } + + private async System.Threading.Tasks.Task ProcessAsync() + { + await _viewModel.ProcessAsync(); + + if (!string.IsNullOrEmpty(_viewModel.ProcessedText) && !_viewModel.IsProcessing) + { + TxtEditor.Text = _viewModel.InputText; + TxtPlaceholder.Visibility = Visibility.Collapsed; + BtnRepeat.IsEnabled = true; + BtnToggleVersion.Visibility = _viewModel.HasSuccessfulResult + ? Visibility.Visible + : Visibility.Collapsed; + UpdateToggleVersionLabel(); + TxtCounters.Text = $"{_viewModel.CharacterCountText} · {_viewModel.WordCountText}"; + } + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(TextProcessingViewModel.IsProcessing)) + { + BtnProcess.Content = _viewModel.MainButtonText; + TxtEditor.IsEnabled = _viewModel.IsEditorEnabled; + BtnModeProofread.IsEnabled = _viewModel.IsModeSwitcherEnabled; + BtnModeTypography.IsEnabled = _viewModel.IsModeSwitcherEnabled; + BtnModeCleanup.IsEnabled = _viewModel.IsModeSwitcherEnabled; + CmbModels.IsEnabled = _viewModel.IsModelSelectorEnabled; + BtnPaste.IsEnabled = _viewModel.IsPasteEnabled; + BtnRepeat.IsEnabled = _viewModel.IsRepeatEnabled; + BtnClear.IsEnabled = _viewModel.IsClearEnabled; + BtnToggleVersion.Visibility = _viewModel.IsToggleVersionVisible + ? Visibility.Visible + : Visibility.Collapsed; + } + else if (e.PropertyName == nameof(TextProcessingViewModel.ToggleButtonText)) + { + UpdateToggleVersionLabel(); + } + else if (e.PropertyName == nameof(TextProcessingViewModel.CharacterCountText) || + e.PropertyName == nameof(TextProcessingViewModel.WordCountText)) + { + TxtCounters.Text = $"{_viewModel.CharacterCountText} · {_viewModel.WordCountText}"; + } + else if (e.PropertyName == nameof(TextProcessingViewModel.IsOverLimit)) + { + TxtCounters.Foreground = _viewModel.IsOverLimit + ? System.Windows.Media.Brushes.OrangeRed + : (System.Windows.Media.Brush)FindResource("MutedText"); + } + else if (e.PropertyName == nameof(TextProcessingViewModel.InputText)) + { + if (TxtEditor.Text != _viewModel.InputText) + { + TxtEditor.Text = _viewModel.InputText; + } + } + } + + private void OnShowNotification(object? sender, string message) + { + new DarkDialog(message) { Owner = this }.ShowDialog(); + } + + private Task OnConfirmAction(string message) + { + var result = new DarkDialog(message, isConfirm: true) { Owner = this }.ShowDialog(); + return Task.FromResult(result == true); + } + + private void UpdateToggleVersionLabel() + { + ToggleVersionLabel.Text = _viewModel.ToggleButtonText; + ToggleVersionIcon.Text = _viewModel.IsShowingOriginal ? "\uE749" : "\uE749"; + } + + private void SaveWindowState() + { + if (WindowState == WindowState.Minimized) return; + + string state = WindowState == WindowState.Maximized ? "Maximized" : "Normal"; + _viewModel.SaveWindowState(Left, Top, ActualWidth, ActualHeight, state); + } + + private void ApplyModeToUI() + { + switch (_viewModel.CurrentMode) + { + case TextProcessingMode.Proofread: + BtnModeProofread.IsChecked = true; + break; + case TextProcessingMode.Typography: + BtnModeTypography.IsChecked = true; + break; + case TextProcessingMode.Cleanup: + BtnModeCleanup.IsChecked = true; + break; + } + TxtModeDescription.Text = _viewModel.ModeDescription; + BtnProcess.Content = _viewModel.MainButtonText; + } +} diff --git a/AiteBar/UtilityButtonCatalog.cs b/AiteBar/UtilityButtonCatalog.cs index 94dd67d..7bfdcec 100644 --- a/AiteBar/UtilityButtonCatalog.cs +++ b/AiteBar/UtilityButtonCatalog.cs @@ -100,6 +100,11 @@ internal static class UtilityButtonCatalog settings => settings.ShowPresetCopilot, (settings, visible) => settings.ShowPresetCopilot = visible); + public static UtilityButtonDefinition TextProcessing { get; } = new( + "TextProcessing", "\uE8A5", "#007ACC", "Main_TextProcessingTooltip", + settings => settings.ShowPresetTextProcessing, + (settings, visible) => settings.ShowPresetTextProcessing = visible); + public static IReadOnlyList All { get; } = [ Search, @@ -117,7 +122,8 @@ internal static class UtilityButtonCatalog ClipboardManager, ShowDesktop, AppsFolder, - Copilot + Copilot, + TextProcessing ]; private static readonly IReadOnlyDictionary ById = diff --git a/TEXT_PROCESSING_UTILITY_FINAL_TASK.md b/TEXT_PROCESSING_UTILITY_FINAL_TASK.md new file mode 100644 index 0000000..b05227f --- /dev/null +++ b/TEXT_PROCESSING_UTILITY_FINAL_TASK.md @@ -0,0 +1,1949 @@ +# Техническое задание: утилита «Обработка текста» для AiteBar + +## 1. Цель + +Создать в AiteBar одну встроенную утилиту **«Обработка текста»** с тремя режимами: + +1. **Проверка текста** +2. **Типографика** +3. **Очистка текста** + +Все режимы работают: + +- в одном WPF-окне; +- с одним основным текстовым редактором; +- с одним набором управляющих кнопок; +- через общий механизм AI-подключений AiteBar. + +Утилита предназначена для технической обработки текста. Она не должна выполнять редактуру, перевод, пересказ, сокращение, дополнение или стилистическое улучшение. + +Главный принцип: + +> Исправлять только то, что относится к выбранному режиму, сохраняя смысл, факты, порядок содержания, авторский тон и формулировки. + +--- + +## 2. Границы задачи + +### 2.1. В задачу входит + +- регистрация одной новой утилиты в AiteBar; +- добавление кнопки запуска на панель системных утилит; +- настройка видимости кнопки; +- создание отдельного WPF-окна; +- три режима обработки в одном интерфейсе; +- интеграция с существующим AI-слоем AiteBar; +- выбор автоматического режима или конкретной модели; +- сохранение выбранной модели; +- отправка текста только по явному действию пользователя; +- хранение исходного текста и результата; +- переключение между исходным текстом и результатом; +- ручное редактирование текста; +- последовательное применение нескольких режимов; +- повтор последней обработки; +- отмена активного запроса; +- обработка ошибок; +- локализация RU/UA/EN/DE; +- поддержка светлой и тёмной тем; +- поддержка масштабирования Windows; +- unit-, integration- и UI-тесты. + +### 2.2. В задачу не входит + +- создание новых AI-провайдеров; +- хранение API-ключей внутри утилиты; +- выбор конкретного API-ключа пользователем; +- редактор системных инструкций; +- пользовательские режимы обработки; +- история всех предыдущих обработок; +- сравнение версий с подсветкой изменений; +- загрузка и разбор PDF, DOCX, HTML и других файлов; +- автоматическое разбиение длинного текста на части; +- перевод; +- суммаризация; +- генерация нового текста; +- стилистическая редактура; +- глобальный хоткей AiteBar; +- автоматическое сохранение введённого текста между запусками. + +--- + +## 3. Архитектура AiteBar + +### 3.1. Создаваемые компоненты + +Минимальный состав: + +- `TextProcessingUtility.cs`; +- `TextProcessingWindow.xaml`; +- `TextProcessingWindow.xaml.cs`; +- `TextProcessingViewModel.cs`; +- `TextProcessingService.cs`; +- `TextProcessingMode.cs`; +- конфигурация системных инструкций; +- тесты. + +Точные имена вспомогательных файлов могут быть изменены в соответствии с текущей структурой проекта. + +### 3.2. Регистрация утилиты + +Класс утилиты должен: + +- быть помечен атрибутом `[Utility]`; +- наследоваться от `UtilityBase`; +- иметь уникальный идентификатор; +- использовать ключ локализации для названия; +- использовать существующую систему иконок и цветов; +- создавать только одно окно; +- при повторном запуске активировать уже открытое окно. + +Рекомендуемый идентификатор: + +```csharp +public override string Id => "TextProcessing"; +``` + +Рекомендуемый ключ: + +```csharp +public override string DisplayNameKey => "Tool_TextProcessing"; +``` + +### 3.3. Позиционирование окна + +При первом запуске использовать существующий механизм AiteBar: + +```csharp +window.ShowNearPanel(settingsService); +``` + +После первого запуска восстанавливать: + +- ширину; +- высоту; +- положение; +- состояние `Normal` или `Maximized`. + +Состояние `Minimized` не восстанавливать. + +Если сохранённая позиция больше не попадает в доступную рабочую область мониторов: + +- удалить или проигнорировать сохранённую позицию; +- открыть окно рядом с панелью AiteBar; +- ограничить окно рабочей областью экрана. + +### 3.4. Кнопка на панели + +Добавить кнопку запуска в `SystemUtilsPanel`. + +Обновить все используемые AiteBar точки подключения: + +- `MainWindow.xaml`; +- tooltip в `ApplyLocalizedText()`; +- context menu в `AttachSystemUtilityContextMenus()`; +- расчёт числа видимых системных кнопок; +- применение видимости; +- обработчик запуска через `LaunchUtilityAsync`; +- keyboard focus traversal, если системные кнопки участвуют в нём. + +### 3.5. Настройка видимости + +Добавить в настройки: + +```csharp +public bool ShowPresetTextProcessing { get; set; } = true; +``` + +Подключить: + +- чекбокс в разделе быстрых утилит; +- загрузку значения; +- сохранение; +- применение; +- локализацию подписи и описания. + +### 3.6. Изоляция ошибок + +Исключение внутри утилиты не должно завершать AiteBar. + +При необработанной ошибке: + +- записать техническую информацию в существующий журнал; +- не записывать исходный или обработанный текст; +- вернуть интерфейс в рабочее состояние, если возможно; +- показать стандартное понятное уведомление. + +--- + +## 4. Локализация + +Добавить одинаковый набор ключей в: + +- `Strings.resx`; +- `Strings.ru.resx`; +- `Strings.uk.resx`; +- `Strings.de.resx`. + +Локализовать: + +- название утилиты; +- общее описание; +- названия режимов; +- описания режимов; +- placeholder; +- подписи кнопок; +- название списка моделей; +- пункт `Автоматически`; +- предупреждение о передаче текста; +- состояния выполнения; +- сообщения об ошибках; +- уведомления; +- пустое состояние списка моделей; +- сообщения об ограничении размера текста; +- подписи счётчиков. + +Все ресурсные файлы должны содержать одинаковый набор ключей и одинаковые форматные параметры. + +--- + +## 5. Интеграция с AI + +### 5.1. Общий принцип + +Утилита не обращается к OpenRouter, Cerebras, Google Gemini, Groq, GitHub Models или Mistral AI напрямую. + +Все запросы проходят через существующий AI-слой AiteBar, который отвечает за: + +- получение списка моделей; +- выбор подключения; +- приоритет подключений; +- quota group; +- cooldown; +- обработку `429`; +- переход к резервному подключению; +- разрешённый fallback; +- запрет автоматического перехода на явно платную модель; +- безопасное получение API-ключа из Windows Credential Manager. + +Окно и ViewModel не должны получать или хранить API-ключи. + +### 5.2. Явное действие пользователя + +Текст отправляется внешнему AI-сервису только после: + +- нажатия основной кнопки; +- нажатия `Ctrl+Enter`. + +Следующие действия ничего не отправляют: + +- открытие окна; +- ввод текста; +- вставка; +- переключение режима; +- выбор модели; +- просмотр исходного текста; +- просмотр результата; +- копирование; +- изменение размера окна. + +### 5.3. Информация о передаче данных + +Рядом со списком моделей или в его tooltip показать: + +> После запуска обработки текст будет отправлен выбранному AI-сервису. + +Сообщение не должно занимать отдельный большой блок и не должно повторяться после каждого запроса. + +### 5.4. Какие модели показывать + +В список включать только модели, которые: + +- доступны через сохранённые и проверенные подключения; +- поддерживают текстовый chat/inference; +- принимают системную и пользовательскую инструкции либо совместимый общий формат; +- способны вернуть текстовый ответ; +- не обозначены AiteBar как явно платные; +- имеют достаточный контекст для текущего текста. + +Не показывать: + +- embedding-модели; +- image-only модели; +- audio-only модели; +- модели без текстового ответа; +- недоступные модели; +- модели с отсутствующим разрешением; +- явно платные модели; +- дубликаты одной и той же логической модели одного сервиса. + +### 5.5. Список моделей + +Название списка: + +**Модель обработки** + +Первый пункт: + +```text +Автоматически +``` + +Конкретные модели отображать в формате: + +```text +Сервис — модель +``` + +Примеры: + +```text +Cerebras — llama-3.3-70b +Google Gemini — gemini-2.5-flash +Groq — llama-3.3-70b-versatile +``` + +Если список большой: + +- группировать модели по сервису, если текущий WPF-контрол это поддерживает без усложнения; +- не добавлять отдельный поиск в первой версии; +- показывать полное название в tooltip при обрезке. + +Недоступные модели в списке не показывать. + +### 5.6. Режим «Автоматически» + +При выборе `Автоматически`: + +- AI-маршрутизатор выбирает доступную бесплатную модель; +- учитывает приоритеты сервисов; +- учитывает quota group; +- может перейти к другому подключению; +- может перейти к другому сервису; +- не может перейти на явно платную модель; +- должен выбрать модель, в контекст которой помещается запрос и ожидаемый ответ. + +### 5.7. Конкретная модель + +При выборе конкретного пункта: + +- фиксируются сервис и модель; +- пользователь не выбирает API-ключ; +- внутри сервиса разрешён переход между подходящими подключениями; +- разрешено использование следующей независимой quota group; +- нельзя незаметно заменить выбранную модель другой; +- нельзя незаметно заменить выбранный сервис другим; +- нельзя перейти на платную модель. + +Если выбранная модель недоступна: + +- запрос не выполняется другой моделью; +- показывается понятная ошибка; +- пользователь может выбрать другую модель или `Автоматически`. + +### 5.8. Сохранение выбора + +Сохранять: + +- `Автоматически`; +- либо идентификатор сервиса и модели. + +При следующем запуске восстановить выбор. + +Не сохранять: + +- API-ключ; +- конкретное подключение; +- quota group, выбранную для отдельного запроса. + +Если сохранённая модель исчезла: + +- выбрать `Автоматически`; +- показать стандартное ненавязчивое уведомление: + +> Ранее выбранная модель больше недоступна. Включён автоматический выбор. + +### 5.9. Отсутствие подключений + +Если нет доступных AI-подключений: + +- основная кнопка неактивна; +- список моделей показывает пустое состояние; +- отображается сообщение: + +> Не добавлена ни одна нейросеть. Добавьте подключение в настройках программы. + +Если существующий механизм позволяет, добавить действие: + +```text +Открыть настройки +``` + +Переход: + +```text +Настройки программы → Нейросети +``` + +### 5.10. Безопасность и логи + +Запрещено записывать в настройки, логи или телеметрию: + +- API-ключи; +- исходный текст; +- обработанный текст; +- содержимое буфера обмена; +- полный AI-запрос; +- полный AI-ответ. + +Допустимо логировать: + +- режим; +- сервис; +- идентификатор модели; +- длительность; +- код ошибки; +- факт fallback; +- количество символов; +- оценочное количество токенов. + +--- + +## 6. Поддерживаемые языки текста + +Утилита должна корректно работать минимум с: + +- русским; +- украинским; +- английским; +- немецким. + +Допускается обработка других языков, если модель их поддерживает, но качество вне четырёх обязательных языков не является критерием приёмки. + +### 6.1. Определение языка + +Язык не выбирается пользователем вручную. + +Модель должна определять язык по содержимому и применять правила к каждому фрагменту текста. + +### 6.2. Смешанный текст + +Если в тексте используются несколько языков: + +- не переводить фрагменты; +- не унифицировать весь текст под один язык; +- применять языковые правила к соответствующим фрагментам; +- сохранять технические фрагменты без изменений; +- не менять имена продуктов, команд, файлов, URL и кода. + +Пример: + +```text +Он открыл файл "report-final.txt" и написал Hello world. +``` + +Режим проверки может исправить пунктуацию, но не должен: + +- переводить `Hello world`; +- менять имя файла; +- типографировать кавычки. + +--- + +## 7. Ограничение размера текста + +### 7.1. Общий принцип + +В первой версии автоматическое разбиение текста на части не реализовывать. + +Один запуск должен соответствовать одному запросу AI. + +### 7.2. Проверка контекста + +Перед отправкой оценить размер запроса в токенах. + +Необходимо учесть: + +- системную инструкцию; +- пользовательский текст; +- служебное обрамление; +- ожидаемый ответ примерно той же длины; +- резерв не менее 15%. + +Запрос можно отправлять только если он помещается в контекст выбранной модели. + +Для `Автоматически` маршрутизатор должен выбирать только модель с достаточным контекстом. + +### 7.3. Жёсткий предел интерфейса + +Для первой версии установить максимальный размер введённого текста: + +```text +50 000 символов +``` + +Значение оформить как именованную конфигурационную константу, а не размножать по коду. + +Если текст больше: + +- не обрезать; +- не отправлять; +- не разбивать; +- показать сообщение: + +> Текст слишком длинный. Максимальный размер — 50 000 символов. + +Если текст меньше 50 000 символов, но не помещается в выбранную модель: + +> Текст не помещается в контекст выбранной модели. Выберите другую модель или сократите текст. + +### 7.4. Счётчик + +При приближении к пределу после 45 000 символов допускается использовать предупреждающий цвет счётчика, соответствующий дизайн-системе AiteBar. + +Сам текст не блокируется при вводе свыше 50 000 символов, но запуск обработки блокируется. Пользователь должен иметь возможность скопировать или сократить текст. + +--- + +## 8. Название и описание + +### 8.1. Название + +**Обработка текста** + +### 8.2. Общее описание + +**Проверка, типографическая обработка и очистка текста без изменения его смысла.** + +Это одна утилита и одно окно. Режимы не регистрируются как отдельные утилиты. + +--- + +## 9. Размер и поведение окна + +### 9.1. Размер по умолчанию + +Предпочтительный размер: + +- ширина: **1280 px**; +- высота: **840 px**. + +Если рабочая область экрана меньше: + +- ширина не более 90% рабочей области; +- высота не более 90% рабочей области; +- окно полностью помещается на экране. + +### 9.2. Минимальный размер + +- ширина: **1000 px**; +- высота: **700 px**. + +Ниже этих значений окно не уменьшается. + +### 9.3. Максимальная ширина содержимого + +Рабочее содержимое не растягивать шире **1600 px**. + +На широких экранах: + +- центрировать рабочую область; +- равномерно оставлять свободное пространство слева и справа. + +### 9.4. Масштабирование + +При увеличении ширины растягиваются: + +- переключатель режимов; +- описание режима; +- текстовый редактор. + +При увеличении высоты растягивается основная рабочая область. + +Не растягиваются: + +- заголовок; +- высота переключателя; +- ширина боковой колонки; +- размеры боковых кнопок; +- высота нижней панели; +- размеры нижних кнопок. + +Прокрутка всего окна не используется. + +--- + +## 10. Дизайн и размеры + +Использовать существующую дизайн-систему AiteBar: + +- ресурсы тем; +- стили кнопок; +- стили полей; +- радиусы; +- цвета; +- шрифты; +- иконки; +- focus; +- hover; +- disabled; +- window chrome. + +Не создавать отдельную визуальную тему. + +### 10.1. Внешние отступы + +- слева/справа: **32 px**; +- сверху/снизу: **24 px**; +- при минимальной ширине боковые отступы можно уменьшить до **24 px**. + +### 10.2. Заголовок + +Название: + +- **30–32 px**; +- `SemiBold`; +- выравнивание слева. + +Описание: + +- **15–16 px**; +- вторичный цвет; +- одна строка при стандартной ширине. + +Отступы: + +- между названием и описанием: **6 px**; +- после описания: **20 px**. + +### 10.3. Переключатель режимов + +Три равных сегмента: + +1. `Проверка текста` +2. `Типографика` +3. `Очистка текста` + +Параметры: + +- высота: **56 px**; +- каждый сегмент занимает треть ширины; +- иконка: **20–22 px**; +- расстояние до текста: **10 px**; +- текст: **16 px**; +- после блока: **12 px**. + +Активный сегмент выделяется стандартным акцентным стилем. + +### 10.4. Описание режима + +- ширина: вся доступная; +- минимальная высота: **64 px**; +- внутренние отступы: **20 px** по горизонтали и **16 px** по вертикали; +- текст: **15–16 px**; +- максимум две строки; +- после блока: **16 px**. + +### 10.5. Рабочая область + +Две колонки: + +- слева — редактор; +- справа — кнопки. + +Расстояние между колонками: **16 px**. + +Минимальная высота: **400 px**. + +### 10.6. Редактор + +- минимальная ширина: **738 px**; +- занимает всё доступное место; +- отступы текста: **24 px** слева/справа, **20 px** сверху/снизу; +- шрифт: **17–18 px**; +- межстрочный интервал: **26–28 px**; +- перенос строк включён; +- горизонтальная прокрутка отключена; +- вертикальная появляется по необходимости. + +Placeholder: + +> Введите текст или вставьте его из буфера обмена + +Поддержать: + +- ввод; +- выделение; +- копирование; +- стандартную вставку; +- вырезание; +- undo; +- redo; +- прокрутку; +- стандартное контекстное меню. + +### 10.7. Строка счётчиков + +Внутри общей рамки редактора снизу: + +```text +1 248 символов · 196 слов +``` + +Параметры: + +- высота: **36 px**; +- тонкий верхний разделитель; +- отступы слева/справа: **20 px**; +- текст: **13–14 px**. + +Показывать только: + +- количество символов; +- количество слов. + +Не добавлять: + +- язык; +- режим; +- модель; +- состояние запроса; +- сообщения об ошибках. + +Пустое состояние: + +```text +0 символов · 0 слов +``` + +### 10.8. Боковая колонка + +- ширина: **144 px**; +- кнопка: **144 × 72 px**; +- расстояние: **12 px**; +- иконка: **22–24 px**; +- подпись: **14–15 px**. + +Кнопки: + +1. `Вставить` +2. `Копировать` +3. `Повторить` +4. `Показать исходный` / `Показать результат` + +### 10.9. Нижняя панель + +- расстояние сверху: **20 px**; +- высота элементов: **52 px**. + +Слева: + +- список моделей: **280 × 52 px**. + +Справа: + +- `Очистить`: **150 × 52 px**; +- основная кнопка: **200 × 52 px**; +- расстояние между кнопками: **12 px**. + +Основная кнопка — единственный сильный акцент. + +--- + +## 11. Состояние при открытии + +При первом запуске: + +- выбран режим `Проверка текста`; +- редактор пуст; +- фокус в редакторе; +- счётчики `0 символов · 0 слов`; +- выбрано `Автоматически`; +- `Вставить` зависит от содержимого буфера; +- `Копировать` неактивно; +- `Повторить` неактивно; +- `Очистить` неактивно; +- основная кнопка неактивна; +- кнопка переключения версий скрыта. + +При следующих запусках восстановить: + +- последний режим; +- выбранную модель; +- размер; +- положение; +- `WindowState`. + +Текст между запусками не сохранять. + +--- + +## 12. Режим «Проверка текста» + +### 12.1. Описание в интерфейсе + +**Исправляет орфографические, грамматические и пунктуационные ошибки без изменения формулировок. Не заменяет кавычки, тире и другие символы на типографские.** + +### 12.2. Основная кнопка + +**Проверить** + +### 12.3. Разрешено + +- исправлять орфографию; +- исправлять грамматику; +- исправлять согласование; +- исправлять окончания; +- исправлять очевидные опечатки; +- добавлять пропущенные знаки препинания; +- удалять лишние знаки препинания; +- переставлять знаки препинания; +- исправлять регистр при очевидной языковой ошибке. + +### 12.4. Запрещено + +- перефразировать; +- заменять слова синонимами; +- улучшать стиль; +- сокращать; +- дополнять; +- менять порядок предложений; +- менять структуру абзацев; +- создавать заголовки; +- создавать новые списки; +- выполнять типографическую обработку. + +### 12.5. Сохранение символов + +Не заменять: + +- `"` на `«»` или `“”`; +- `'` на типографский апостроф; +- `-` на `–` или `—`; +- `...` на `…`; +- обычные пробелы на неразрывные. + +Если требуется добавить знак, использовать обычный клавиатурный знак либо тот тип, который уже последовательно используется в исходном тексте. + +### 12.6. Системная инструкция + +```text +Ты выполняешь только проверку орфографии, грамматики и пунктуации. + +Исправь орфографические и грамматические ошибки, согласование, окончания, очевидные опечатки, регистр и пунктуацию. + +Строго запрещено: +- перефразировать текст; +- заменять слова синонимами; +- улучшать стиль; +- сокращать или дополнять текст; +- менять порядок предложений; +- менять структуру абзацев; +- создавать новые заголовки или списки; +- переводить текст; +- выполнять типографическую обработку. + +Сохраняй исходный тип символов: +- обычные кавычки " не заменяй на типографские; +- обычный апостроф ' не заменяй; +- дефис или минус - не заменяй на короткое или длинное тире; +- три точки ... не заменяй знаком многоточия; +- обычные пробелы не заменяй неразрывными. + +Не изменяй URL, адреса электронной почты, пути к файлам, имена файлов, команды, программный код, теги, переменные, номера версий, артикулы и идентификаторы, кроме очевидной пунктуации вокруг них. + +Если текст содержит несколько языков, проверяй каждый фрагмент на его языке и ничего не переводи. + +Верни только исправленный текст. Не добавляй пояснений, заголовков, списка исправлений, Markdown-обёртки или фраз вроде "Готово" и "Исправленный текст". +``` + +--- + +## 13. Режим «Типографика» + +### 13.1. Описание в интерфейсе + +**Расставляет правильные кавычки, тире, пробелы и другие типографские знаки с учётом языка текста.** + +### 13.2. Основная кнопка + +**Оформить** + +### 13.3. Разрешено + +- определять язык фрагментов; +- оформлять обычные и вложенные кавычки; +- различать дефис, короткое и длинное тире; +- заменять `...` на `…`; +- исправлять пробелы возле знаков; +- добавлять неразрывные пробелы; +- оформлять диапазоны; +- оформлять инициалы; +- оформлять сокращения; +- оформлять проценты; +- оформлять валюты; +- оформлять градусы; +- оформлять единицы измерения; +- удалять лишние пробелы; +- унифицировать типографские символы. + +### 13.4. Запрещено + +- менять слова; +- исправлять стиль; +- перефразировать; +- сокращать; +- дополнять; +- перестраивать абзацы; +- переводить; +- менять смысл; +- исправлять спорные языковые ошибки, не относящиеся к типографике. + +### 13.5. Системная инструкция + +```text +Ты выполняешь только типографическое оформление текста. + +Определи язык каждого содержательного фрагмента и примени принятые для него типографские правила: +- правильные внешние и вложенные кавычки; +- дефис, короткое и длинное тире по назначению; +- знак многоточия; +- корректные пробелы возле знаков препинания; +- неразрывные пробелы; +- оформление диапазонов; +- оформление инициалов и сокращений; +- оформление процентов, валют, градусов и единиц измерения; +- удаление повторяющихся и лишних пробелов. + +Строго запрещено: +- менять слова и формулировки; +- исправлять стиль; +- перефразировать; +- сокращать или дополнять текст; +- менять порядок предложений; +- перестраивать абзацы; +- переводить; +- изменять смысл. + +Не изменяй содержимое URL, адресов электронной почты, путей к файлам, имён файлов, команд, программного кода, HTML/XML-тегов, Markdown-разметки, переменных, шаблонов, номеров версий, артикулов и идентификаторов. + +Если текст смешанный, применяй правила к каждому языковому фрагменту отдельно. Не типографируй технические фрагменты. + +Верни только оформленный текст. Не добавляй пояснений, заголовков, списка изменений, Markdown-обёртки или фраз вроде "Готово" и "Оформленный текст". +``` + +--- + +## 14. Режим «Очистка текста» + +### 14.1. Описание в интерфейсе + +**Удаляет технический мусор, лишние переносы и служебные символы после копирования из PDF, документов и веб-страниц.** + +### 14.2. Основная кнопка + +**Очистить текст** + +### 14.3. Разрешено + +- объединять строки, разорванные внутри предложения; +- восстанавливать слова, разорванные переносом; +- удалять повторяющиеся пробелы; +- удалять лишние пустые строки; +- удалять невидимые и служебные символы; +- удалять очевидные номера страниц; +- удалять повторяющиеся колонтитулы; +- удалять артефакты копирования; +- восстанавливать реальные абзацы; +- сохранять существующие списки. + +### 14.4. Запрещено + +- исправлять орфографию; +- исправлять грамматику; +- менять пунктуацию; +- типографировать; +- перефразировать; +- сокращать; +- удалять содержательные фрагменты; +- переводить; +- менять смысл. + +Если нельзя уверенно определить, является ли элемент мусором, его необходимо сохранить. + +### 14.5. Системная инструкция + +```text +Ты выполняешь только очистку текста от технических артефактов копирования. + +Разрешено: +- объединять строки, случайно разорванные внутри предложения; +- восстанавливать слова, разорванные переносом строки; +- удалять повторяющиеся пробелы; +- удалять лишние пустые строки; +- удалять невидимые и служебные символы; +- удалять очевидные номера страниц; +- удалять повторяющиеся колонтитулы; +- удалять явные артефакты копирования; +- восстанавливать реальные границы абзацев; +- сохранять настоящие списки и структуру документа. + +Строго запрещено: +- исправлять орфографию; +- исправлять грамматику; +- менять пунктуацию; +- выполнять типографические замены; +- перефразировать; +- сокращать или дополнять; +- переводить; +- удалять содержательный текст; +- менять смысл. + +Не изменяй URL, адреса электронной почты, пути, имена файлов, команды, программный код, теги, переменные, номера версий, артикулы и идентификаторы. + +Если нет уверенности, что фрагмент является техническим мусором, сохрани его. + +Верни только очищенный текст. Не добавляй пояснений, заголовков, списка удалённых элементов, Markdown-обёртки или фраз вроде "Готово" и "Очищенный текст". +``` + +--- + +## 15. Формирование AI-запроса + +Запрос должен состоять из: + +1. системной инструкции выбранного режима; +2. пользовательского текста без дополнительных редакторских указаний. + +Не добавлять в пользовательский текст: + +- название модели; +- техническую диагностику; +- историю прошлых запросов; +- исходный и предыдущий результат одновременно; +- другие режимы. + +Каждый запуск является отдельным stateless-запросом. + +Температуру и другие параметры использовать в соответствии с общим AI-слоем AiteBar. Для этой утилиты предпочтительно минимальное или низкое значение вариативности, если провайдер поддерживает параметр. + +--- + +## 16. Очистка ответа AI + +### 16.1. Общий принцип + +Основным средством получения чистого результата должна быть системная инструкция. + +Постобработка ответа применяется только к очевидному техническому обрамлению. + +### 16.2. Разрешено удалить + +- один внешний Markdown code fence, если внутри находится весь ответ; +- одну служебную строку перед текстом: + - `Исправленный текст:` + - `Оформленный текст:` + - `Очищенный текст:` + - `Результат:` + - локализованные очевидные аналоги; +- внешнюю пару кавычек, только если она охватывает весь многострочный ответ и очевидно добавлена моделью. + +### 16.3. Запрещено + +- удалять обычные первые или последние строки; +- обрезать содержимое по двоеточию; +- удалять заголовок, который присутствовал в исходном тексте; +- использовать агрессивные регулярные выражения; +- повторно исправлять ответ; +- нормализовать пробелы для всех режимов; +- применять типографику к результату проверки; +- применять грамматическую проверку к результату очистки. + +### 16.4. Пустой ответ + +Ответ считается пустым, если после удаления технического обрамления он состоит только из пробелов. + +В этом случае: + +- не заменять текст в редакторе; +- показать ошибку; +- сохранить исходный текст. + +--- + +## 17. Общие правила результата + +Во всех режимах сохранять: + +- смысл; +- факты; +- авторский тон; +- порядок содержания; +- имена; +- числа; +- даты; +- ссылки; +- реквизиты. + +Максимально защищать: + +- URL; +- email; +- номера телефонов; +- пути; +- имена файлов; +- команды; +- код; +- теги; +- переменные; +- шаблоны; +- идентификаторы; +- версии; +- артикулы. + +Ответ AI должен содержать только обработанный текст. + +--- + +## 18. Логика запуска обработки + +После запуска: + +1. Проверить, что текст не пуст. +2. Проверить лимит 50 000 символов. +3. Проверить наличие доступной модели. +4. Проверить, что запрос помещается в контекст. +5. Сохранить текущий отображаемый текст как `OriginalText`. +6. Сохранить выбранный режим. +7. Сохранить логический выбор модели. +8. Создать `CancellationTokenSource`. +9. Сформировать stateless-запрос. +10. Вызвать общий AI-сервис асинхронно. +11. Заблокировать действия, способные изменить запрос. +12. Дождаться ответа. +13. Проверить ответ. +14. Удалить только очевидное техническое обрамление. +15. Сохранить результат в `ProcessedText`. +16. Показать результат в редакторе. +17. Показать кнопку переключения версий. +18. Активировать `Повторить`. +19. Пересчитать счётчики. + +UI не должен зависать. + +--- + +## 19. Исходный текст и результат + +Хранить: + +- `OriginalText`; +- `ProcessedText`; +- `ProcessedMode`; +- `SelectedModel`; +- `IsShowingOriginal`; +- состояние наличия ручных изменений результата. + +### 19.1. Показать исходный + +- показать `OriginalText`; +- изменить подпись на `Показать результат`; +- пересчитать счётчики. + +### 19.2. Показать результат + +- показать `ProcessedText`; +- изменить подпись на `Показать исходный`; +- пересчитать счётчики. + +Переключение версий ничего не отправляет. + +--- + +## 20. Переключение режима + +Переключение режима: + +- не изменяет текст; +- не отправляет запрос; +- меняет описание режима; +- меняет основную кнопку; +- меняет системную инструкцию следующего запроса. + +При следующем запуске обрабатывается текст, который **в данный момент отображается в редакторе**. + +Если отображается результат, он становится исходным для следующего режима. + +Если отображается первоначальный исходный текст, именно он становится исходным для следующего режима. + +Кнопка переключения версий не скрывается только из-за смены режима. + +--- + +## 21. Последовательная обработка + +Пример: + +1. Пользователь запускает `Проверка текста`. +2. Получает результат. +3. Оставляет результат отображённым. +4. Переключает режим на `Типографика`. +5. Нажимает `Оформить`. + +В этом случае результат проверки становится новым `OriginalText`. + +После успешной новой обработки предыдущая пара: + +```text +OriginalText / ProcessedText +``` + +заменяется новой парой. + +История цепочки не хранится. + +--- + +## 22. Ручное редактирование + +### 22.1. Редактирование результата + +Если пользователь редактирует отображаемый результат: + +- изменения записываются в `ProcessedText`; +- `OriginalText` сохраняется; +- переключение версий продолжает работать; +- результат считается пользовательской версией. + +### 22.2. Редактирование исходного текста + +Если пользователь показывает исходный текст и изменяет его: + +- старый `ProcessedText` становится недействительным; +- переключение версий скрывается; +- `Повторить` отключается; +- изменённый текст становится текущим рабочим текстом. + +### 22.3. Повтор после ручного изменения результата + +Кнопка `Повторить` всегда использует сохранённый `OriginalText`, а не вручную отредактированный результат. + +Перед повтором, если результат был изменён вручную, показать подтверждение стандартным механизмом AiteBar: + +> Повторная обработка заменит изменённый результат. Продолжить? + +Варианты: + +- `Повторить` +- `Отмена` + +### 22.4. Новый запуск после ручного изменения результата + +Если пользователь нажимает основную кнопку, пока отображается вручную изменённый результат: + +- именно отображаемый текст становится новым `OriginalText`; +- дополнительное подтверждение не требуется; +- это считается последовательной обработкой. + +--- + +## 23. Поведение кнопок + +### 23.1. Вставить + +Боковая кнопка `Вставить`: + +- активна при наличии текстового содержимого в буфере; +- полностью заменяет текущий текст; +- не вставляет в позицию курсора; +- сбрасывает старый результат; +- скрывает переключение версий; +- отключает `Повторить`. + +Стандартное `Ctrl+V` в редакторе работает как обычная вставка в позицию курсора. + +### 23.2. Копировать + +- копирует весь отображаемый текст; +- неактивна при пустом поле; +- после копирования может показать стандартное краткое подтверждение. + +### 23.3. Повторить + +Повторяет последнюю успешную обработку: + +- с сохранённым `OriginalText`; +- в сохранённом режиме; +- с сохранённым логическим выбором модели. + +Конкретный API-ключ не фиксируется. + +### 23.4. Очистить + +Сбрасывает: + +- редактор; +- исходный текст; +- результат; +- переключение версий; +- повтор; +- счётчики. + +Не сбрасывает: + +- режим; +- выбранную модель; +- размер окна; +- положение окна. + +После очистки фокус возвращается в редактор. + +--- + +## 24. Состояния интерфейса + +### 24.1. Пустое поле + +- основная кнопка неактивна; +- `Копировать` неактивно; +- `Повторить` неактивно; +- `Очистить` неактивно; +- переключение версий скрыто; +- счётчики нулевые. + +### 24.2. Текст введён + +- `Копировать` активно; +- `Очистить` активно; +- основная кнопка активна при доступной модели и допустимом размере; +- `Повторить` зависит от предыдущей успешной обработки. + +### 24.3. Текст превышает лимит + +- редактор остаётся доступен; +- копирование и очистка доступны; +- основная кнопка неактивна; +- счётчик отображает предупреждающее состояние; +- показывается tooltip или сообщение о лимите. + +### 24.4. Выполняется запрос + +Заблокировать: + +- редактор; +- переключатель режимов; +- список моделей; +- боковую кнопку `Вставить`; +- `Очистить`; +- `Повторить`; +- переключение версий. + +Основная кнопка превращается в: + +**Отменить** + +Повторное выполнение исключено. + +### 24.5. Успешный результат + +- показать результат; +- сохранить исходный текст; +- показать переключение версий; +- включить `Повторить`; +- обновить счётчики; +- вернуть фокус в редактор. + +### 24.6. Ошибка + +- не терять исходный текст; +- не подставлять частичный ответ; +- вернуть UI в рабочее состояние; +- сохранить режим и выбор модели; +- показать стандартное уведомление AiteBar. + +### 24.7. Отмена + +- отменить запрос через `CancellationToken`; +- не подставлять частичный результат; +- восстановить текст, отображавшийся до запуска; +- вернуть UI в рабочее состояние. + +--- + +## 25. Закрытие окна + +Текст между запусками не сохраняется. + +Если редактор пуст, окно закрывается без подтверждения. + +Если редактор содержит текст, но пользователь не менял его после последней успешной обработки, окно закрывается без подтверждения. + +Если есть несохранённый вручную введённый или изменённый текст, использовать существующий общий механизм AiteBar для подтверждения закрытия. + +Если общего механизма нет, показать: + +> Закрыть окно? Введённый текст не сохраняется. + +Кнопки: + +- `Закрыть` +- `Отмена` + +Не создавать отдельный нестандартный дизайн диалога. + +--- + +## 26. Ошибки + +Обработать: + +- отсутствие интернета; +- `401`; +- `403`; +- `402`; +- `429`; +- `5xx`; +- тайм-аут; +- недоступную модель; +- исчезновение выбранной модели; +- пустой ответ; +- слишком длинный текст; +- недостаточный контекст модели; +- отмену; +- ошибку буфера обмена; +- неожиданное исключение. + +AI-слой отвечает за: + +- авторизацию; +- ключи; +- quota group; +- cooldown; +- допустимый fallback. + +Окно отвечает за: + +- понятное сообщение; +- сохранность текста; +- корректное состояние элементов; +- возможность повторить действие. + +--- + +## 27. Горячие клавиши + +- `Ctrl+C` — стандартное копирование выделения; +- `Ctrl+V` — стандартная вставка; +- `Ctrl+X` — вырезать; +- `Ctrl+A` — выделить всё; +- `Ctrl+Z` — undo; +- `Ctrl+Y` — redo; +- `Ctrl+Enter` — запустить текущий режим; +- `Esc` — отменить активный запрос. + +Боковая кнопка `Копировать` копирует весь текст, в отличие от стандартного `Ctrl+C`. + +Глобальный хоткей AiteBar не добавлять. + +--- + +## 28. Доступность и DPI + +Добавить `AutomationProperties.Name` и при необходимости `HelpText` для: + +- переключателя режимов; +- редактора; +- списка моделей; +- всех кнопок; +- счётчиков; +- предупреждения о передаче данных; +- пустого состояния моделей. + +Проверить: + +- логичный tab order; +- заметный focus; +- полное управление с клавиатуры; +- различимые disabled-состояния; +- отсутствие зависимости только от цвета; +- Windows scaling 100%, 125%, 150%, 200%; +- длинные немецкие подписи; +- длинные названия моделей. + +--- + +## 29. Светлая и тёмная темы + +Использовать существующие dynamic resources AiteBar. + +Проверить: + +- фон окна; +- фон редактора; +- границы; +- строку счётчиков; +- активный сегмент; +- disabled; +- hover; +- focus; +- основную кнопку; +- выпадающий список; +- уведомления; +- предупреждающее состояние лимита. + +Не использовать жёстко заданные цвета при наличии ресурсов темы. + +--- + +## 30. Сохранение состояния + +Сохранять: + +- размер; +- положение; +- `WindowState`; +- последний режим; +- логический выбор модели. + +Не сохранять: + +- текст; +- исходный текст; +- результат; +- содержимое буфера; +- API-ключ; +- конкретное подключение отдельного запроса. + +--- + +## 31. Рекомендуемое разделение кода + +### 31.1. Window/ViewModel + +Отвечает за: + +- UI; +- команды; +- состояния кнопок; +- счётчики; +- выбор режима; +- выбор модели; +- переключение версий; +- подтверждения; +- сохранение состояния окна. + +### 31.2. TextProcessingService + +Отвечает за: + +- системные инструкции; +- формирование запроса; +- оценку контекста; +- вызов общего AI-слоя; +- отмену; +- проверку пустого ответа; +- безопасное удаление технического обрамления. + +### 31.3. Модель состояния + +Содержит: + +- текущий режим; +- текущий отображаемый текст; +- `OriginalText`; +- `ProcessedText`; +- выбранную модель; +- состояние выполнения; +- отображаемую версию; +- признак ручного изменения результата. + +Сетевую логику не размещать непосредственно в обработчиках кнопок. + +--- + +## 32. Тестовые примеры + +Тесты AI не требуют полного побуквенного совпадения во всех допустимых случаях. Они должны проверять обязательные ограничения режима и отсутствие запрещённых изменений. + +### 32.1. Проверка текста + +#### Пример 1 + +Исходный текст: + +```text +Он сказал "я вернусь" но не уточнил когда. +``` + +Ожидаемый результат: + +```text +Он сказал "я вернусь", но не уточнил, когда. +``` + +Обязательно: + +- обычные кавычки сохранены; +- слова не заменены; +- двоеточие после `сказал` не добавлено. + +#### Пример 2 + +Исходный текст: + +```text +Ми перевірили документ але помилку не знайшли. +``` + +Ожидаемый результат: + +```text +Ми перевірили документ, але помилку не знайшли. +``` + +#### Пример 3 + +Исходный текст: + +```text +This files contains two error. +``` + +Ожидаемый результат: + +```text +This file contains two errors. +``` + +#### Пример 4 + +Исходный текст: + +```text +Версия app-1.2.0 работает но файл C:\Temp\report-final.txt не найден. +``` + +Ожидаемый результат: + +```text +Версия app-1.2.0 работает, но файл C:\Temp\report-final.txt не найден. +``` + +Обязательно сохранить версию и путь. + +#### Пример 5 + +Исходный текст: + +```text +Напиши на test@example.com если вопрос не решится... +``` + +Ожидаемый результат: + +```text +Напиши на test@example.com, если вопрос не решится... +``` + +Обязательно сохранить три точки. + +### 32.2. Типографика + +#### Пример 1 + +Исходный текст: + +```text +Он сказал "я вернусь" - и ушел... +``` + +Ожидаемый результат для русского фрагмента: + +```text +Он сказал «я вернусь» — и ушел… +``` + +Слова `ушел` не исправлять на `ушёл`, поскольку это не типографика. + +#### Пример 2 + +Исходный текст: + +```text +Цена 100 грн, скидка 20 %, температура 25 ° C. +``` + +Ожидаемый результат: + +```text +Цена 100 грн, скидка 20 %, температура 25 °C. +``` + +Допускаются неразрывные пробелы согласно правилам реализации. + +#### Пример 3 + +Исходный текст: + +```text +Страницы 10 - 15. +``` + +Ожидаемый результат: + +```text +Страницы 10–15. +``` + +#### Пример 4 + +Исходный текст: + +```text +Открой "C:\Temp\report-final.txt" и перейди на https://example.com/a-b. +``` + +Путь и URL не изменять. + +#### Пример 5 + +Исходный текст: + +```text +He opened "report-final.txt" - потом закрыл файл. +``` + +Не переводить. Имя файла сохранить. Типографику применять к языковым фрагментам без повреждения технического текста. + +### 32.3. Очистка текста + +#### Пример 1 + +Исходный текст: + +```text +Это предложение было +разорвано внутри строки. +``` + +Ожидаемый результат: + +```text +Это предложение было разорвано внутри строки. +``` + +#### Пример 2 + +Исходный текст: + +```text +Документ содер- +жит важный текст. +``` + +Ожидаемый результат: + +```text +Документ содержит важный текст. +``` + +#### Пример 3 + +Исходный текст: + +```text +Первый абзац. + + +Второй абзац. +``` + +Ожидаемый результат: + +```text +Первый абзац. + +Второй абзац. +``` + +#### Пример 4 + +Исходный текст: + +```text +1. Первый пункт +2. Второй пункт +``` + +Список должен сохраниться. + +#### Пример 5 + +Исходный текст: + +```text +Файл report-final.txt +находится в C:\Temp\ +и доступен по https://example.com/a-b. +``` + +Не повреждать имя файла, путь и URL. Не исправлять язык или пунктуацию. + +--- + +## 33. Автоматические тесты + +### 33.1. Unit-тесты + +Проверить: + +- конфигурацию трёх режимов; +- подписи основных кнопок; +- системные инструкции; +- отсутствие типографических правил в проверке; +- отсутствие грамматических правил в очистке; +- сохранение исходного типа символов; +- безопасную очистку внешнего code fence; +- безопасное удаление служебной строки; +- отсутствие агрессивной обрезки ответа; +- пустой ответ; +- счётчики; +- лимит 50 000 символов; +- оценку контекста; +- сброс результата после изменения исходного текста; +- сохранение ручных изменений результата; +- переключение версий; +- последовательную обработку; +- повтор; +- подтверждение перезаписи ручного результата; +- очистку; +- отмену. + +### 33.2. Integration-тесты + +Проверить: + +- загрузку только подходящих chat-моделей; +- исключение явно платных моделей; +- `Автоматически`; +- конкретную модель; +- сохранение выбора; +- исчезновение сохранённой модели; +- fallback между подключениями; +- запрет подмены конкретной модели; +- запрет платного fallback; +- `401`, `403`, `402`, `429`, `5xx`; +- отсутствие ключей и текста в логах. + +### 33.3. UI-тесты или ручной чек-лист + +Проверить: + +- первый запуск; +- повторный запуск при открытом окне; +- открытие рядом с панелью; +- восстановление размера; +- недоступную сохранённую позицию; +- минимальный размер; +- maximized; +- DPI 100–200%; +- light/dark; +- RU/UA/EN/DE; +- длинные немецкие подписи; +- длинное название модели; +- пустой список моделей; +- все режимы; +- все состояния кнопок; +- стандартную вставку и кнопку полной замены; +- текст больше 50 000 символов; +- недостаточный контекст модели; +- отмену; +- ошибку сети; +- последовательную обработку; +- ручное редактирование исходного текста; +- ручное редактирование результата; +- повтор после ручного изменения; +- подтверждение закрытия. + +--- + +## 34. Порядок реализации + +1. Выбрать ближайшее существующее окно-образец. +2. Создать и зарегистрировать утилиту. +3. Добавить кнопку панели и настройку видимости. +4. Добавить локализацию. +5. Реализовать сетку, размеры и восстановление окна. +6. Реализовать редактор, счётчики и боковые кнопки. +7. Реализовать состояния исходного текста и результата. +8. Реализовать три режима и системные инструкции. +9. Подключить каталог моделей. +10. Реализовать фильтрацию моделей. +11. Реализовать `Автоматически` и конкретную модель. +12. Реализовать сохранение выбора модели. +13. Реализовать проверку размера и контекста. +14. Подключить общий AI-сервис. +15. Реализовать отмену и обработку ошибок. +16. Реализовать безопасную очистку ответа. +17. Реализовать последовательную обработку. +18. Реализовать ручное редактирование и подтверждения. +19. Добавить предупреждение о передаче текста. +20. Добавить accessibility. +21. Добавить unit- и integration-тесты. +22. Проверить UI, темы, DPI и локализации. +23. Выполнить полный build и test suite. + +--- + +## 35. Критерии приёмки + +Задача выполнена, если: + +1. Добавлена одна утилита `Обработка текста`. +2. Повторный запуск активирует существующее окно. +3. Кнопка добавлена на панель. +4. Видимость кнопки настраивается. +5. В одном окне работают три режима. +6. Текст не удаляется при переключении режима. +7. Все режимы используют один редактор. +8. Модели загружаются из общей AI-инфраструктуры. +9. Показываются только подходящие бесплатные text/chat-модели. +10. Есть режим `Автоматически`. +11. Пользователь может выбрать конкретный сервис и модель. +12. Пользователь не выбирает API-ключ. +13. Выбор модели сохраняется между запусками. +14. Исчезнувшая модель заменяется на `Автоматически` с уведомлением. +15. Конкретная модель не подменяется другой. +16. Платный fallback запрещён. +17. Текст отправляется только по явному действию. +18. В интерфейсе указано, что текст будет передан AI-сервису. +19. Поддерживаются русский, украинский, английский и немецкий текст. +20. Смешанный текст не переводится. +21. Максимальный размер — 50 000 символов. +22. Автоматическое разбиение текста отсутствует. +23. Запрос проверяется на соответствие контексту модели. +24. Исходный текст и результат хранятся отдельно. +25. Работает переключение версий. +26. Можно редактировать результат. +27. Изменение исходного текста сбрасывает устаревший результат. +28. Повтор после ручного изменения требует подтверждения. +29. Работает последовательная обработка. +30. Проверка не выполняет типографические замены. +31. Типографика не меняет слова и содержание. +32. Очистка не исправляет грамматику и пунктуацию. +33. Используются зафиксированные системные инструкции. +34. Ответ AI не содержит пояснений. +35. Постобработка не удаляет содержательный текст. +36. Ошибка не приводит к потере текста. +37. Запрос можно отменить. +38. UI не зависает. +39. Размер и положение окна восстанавливаются. +40. Минимальный размер не ломает интерфейс. +41. Содержимое maximized-окна не шире 1600 px. +42. Строка внутри редактора содержит только символы и слова. +43. Светлая и тёмная темы корректны. +44. DPI 100–200% не ломает компоновку. +45. RU/UA/EN/DE имеют одинаковые ключи. +46. Текст и ключи не попадают в логи и телеметрию. +47. Закрытие с несохранённым текстом требует подтверждения. +48. Полный build проходит без ошибок. +49. Все существующие и новые тесты проходят. From 3e24b456efa30385f7f2702d9a65dd86ea9e034e Mon Sep 17 00:00:00 2001 From: Codebdbd Date: Tue, 21 Jul 2026 05:24:50 +0300 Subject: [PATCH 09/19] Simplify LoadModelsAsync collection initialization --- AiteBar/TextProcessingViewModel.cs | 54 +++++++++++++----------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/AiteBar/TextProcessingViewModel.cs b/AiteBar/TextProcessingViewModel.cs index f7776fd..760bb29 100644 --- a/AiteBar/TextProcessingViewModel.cs +++ b/AiteBar/TextProcessingViewModel.cs @@ -211,41 +211,41 @@ public string StatusMessage public async Task LoadModelsAsync() { - Models.Clear(); - Models.Add(new ModelItem(null, null, LocalizationService.Get("TextProcessing_ModelAuto"), true)); + var allModels = new List + { + new(null, null, LocalizationService.Get("TextProcessing_ModelAuto"), true) + }; AppSettings settings = _settingsService.Settings; AiSettings? aiSettings = settings.Ai; - if (aiSettings?.Connections == null || aiSettings.Connections.Count == 0) + if (aiSettings?.Connections != null) { - IsModelAvailable = false; - return; - } - - var eligible = new List(); - foreach (AiConnectionSettings connection in aiSettings.Connections.Where(c => c.IsEnabled)) - { - try + foreach (AiConnectionSettings connection in aiSettings.Connections.Where(c => c.IsEnabled)) { - IReadOnlyList models = await _gateway.GetModelsAsync(connection, CancellationToken.None).ConfigureAwait(false); - foreach (AiModelDescriptor model in models.Where(m => - !m.IsDeprecated && - (m.Capabilities & AiCapabilities.Text) == AiCapabilities.Text && - m.CostStatus is AiCostStatus.VerifiedFree or AiCostStatus.FreeTierAvailable)) + try { - string display = $"{connection.DisplayName} — {model.DisplayName}"; - eligible.Add(new ModelItem(connection.ProviderId, model.ModelId, display, true)); + IReadOnlyList models = await _gateway.GetModelsAsync(connection, CancellationToken.None).ConfigureAwait(false); + foreach (AiModelDescriptor model in models.Where(m => + !m.IsDeprecated && + (m.Capabilities & AiCapabilities.Text) == AiCapabilities.Text && + m.CostStatus is AiCostStatus.VerifiedFree or AiCostStatus.FreeTierAvailable)) + { + string display = $"{connection.DisplayName} — {model.DisplayName}"; + allModels.Add(new ModelItem(connection.ProviderId, model.ModelId, display, true)); + } + } + catch + { + // Connection unavailable, skip } - } - catch - { - // Connection unavailable, skip } } - foreach (ModelItem item in eligible.OrderBy(m => m.Display)) + Models.Clear(); + var sortedModels = allModels.Take(1).Concat(allModels.Skip(1).OrderBy(m => m.Display)).ToList(); + foreach (var model in sortedModels) { - Models.Add(item); + Models.Add(model); } IsModelAvailable = Models.Count > 1; @@ -261,12 +261,6 @@ public async Task ProcessAsync() StatusMessage = string.Empty; - if (IsOverLimit) - { - StatusMessage = LocalizationService.Get("TextProcessing_ErrorTooLong"); - return; - } - string textToShow = _isShowingOriginal ? _originalText : InputText; if (_hasSuccessfulResult && !_isShowingOriginal && _isModifiedManually) { From 8c7c08cd039849e528c1c2094ca43f3b10a456d1 Mon Sep 17 00:00:00 2001 From: Codebdbd Date: Tue, 21 Jul 2026 05:26:28 +0300 Subject: [PATCH 10/19] Add setters for IsEditorEnabled, IsModeSwitcherEnabled, IsModelSelectorEnabled --- AiteBar/TextProcessingViewModel.cs | 35 +++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/AiteBar/TextProcessingViewModel.cs b/AiteBar/TextProcessingViewModel.cs index 760bb29..c1e7bbc 100644 --- a/AiteBar/TextProcessingViewModel.cs +++ b/AiteBar/TextProcessingViewModel.cs @@ -170,9 +170,38 @@ public string StatusMessage public bool HasStatusMessage => !string.IsNullOrEmpty(_statusMessage); - public bool IsEditorEnabled => !_isProcessing && _isEditorEnabled; - public bool IsModeSwitcherEnabled => !_isProcessing && _isModeSwitcherEnabled; - public bool IsModelSelectorEnabled => !_isProcessing && _isModelSelectorEnabled; + public bool IsEditorEnabled + { + get => !_isProcessing && _isEditorEnabled; + set + { + if (_isEditorEnabled == value) return; + _isEditorEnabled = value; + OnPropertyChanged(); + } + } + + public bool IsModeSwitcherEnabled + { + get => !_isProcessing && _isModeSwitcherEnabled; + set + { + if (_isModeSwitcherEnabled == value) return; + _isModeSwitcherEnabled = value; + OnPropertyChanged(); + } + } + + public bool IsModelSelectorEnabled + { + get => !_isProcessing && _isModelSelectorEnabled; + set + { + if (_isModelSelectorEnabled == value) return; + _isModelSelectorEnabled = value; + OnPropertyChanged(); + } + } public bool IsPasteEnabled => !_isProcessing; public bool IsClearEnabled => !_isProcessing && CharacterCount > 0; public bool IsRepeatEnabled => !_isProcessing && _hasSuccessfulResult; From 6909859bf3b9558e2d7d230a339b459f621f3562 Mon Sep 17 00:00:00 2001 From: Codebdbd Date: Tue, 21 Jul 2026 05:28:22 +0300 Subject: [PATCH 11/19] =?UTF-8?q?Prioritize=20preferred=20(=D0=BE=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9)=20model=20from=20each=20c?= =?UTF-8?q?onnection=20in=20model=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AiteBar/TextProcessingViewModel.cs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/AiteBar/TextProcessingViewModel.cs b/AiteBar/TextProcessingViewModel.cs index c1e7bbc..07d35c0 100644 --- a/AiteBar/TextProcessingViewModel.cs +++ b/AiteBar/TextProcessingViewModel.cs @@ -254,10 +254,26 @@ public async Task LoadModelsAsync() try { IReadOnlyList models = await _gateway.GetModelsAsync(connection, CancellationToken.None).ConfigureAwait(false); - foreach (AiModelDescriptor model in models.Where(m => + var eligibleModels = models.Where(m => !m.IsDeprecated && (m.Capabilities & AiCapabilities.Text) == AiCapabilities.Text && - m.CostStatus is AiCostStatus.VerifiedFree or AiCostStatus.FreeTierAvailable)) + m.CostStatus is AiCostStatus.VerifiedFree or AiCostStatus.FreeTierAvailable).ToList(); + + // Add preferred model first if present and eligible + if (!string.IsNullOrWhiteSpace(connection.PreferredModelId)) + { + var preferredModel = eligibleModels.FirstOrDefault(m => + string.Equals(m.ModelId, connection.PreferredModelId, StringComparison.OrdinalIgnoreCase)); + if (preferredModel != null) + { + string display = $"{connection.DisplayName} — {preferredModel.DisplayName}"; + allModels.Add(new ModelItem(connection.ProviderId, preferredModel.ModelId, display, true)); + eligibleModels.Remove(preferredModel); + } + } + + // Add remaining eligible models + foreach (AiModelDescriptor model in eligibleModels) { string display = $"{connection.DisplayName} — {model.DisplayName}"; allModels.Add(new ModelItem(connection.ProviderId, model.ModelId, display, true)); @@ -271,8 +287,12 @@ public async Task LoadModelsAsync() } Models.Clear(); - var sortedModels = allModels.Take(1).Concat(allModels.Skip(1).OrderBy(m => m.Display)).ToList(); - foreach (var model in sortedModels) + // Keep auto model first, then sort the rest but keep preferred models in their order relative to connections + var autoModel = allModels[0]; + var otherModels = allModels.Skip(1).ToList(); + otherModels.Sort((a, b) => string.Compare(a.Display, b.Display, StringComparison.CurrentCultureIgnoreCase)); + Models.Add(autoModel); + foreach (var model in otherModels) { Models.Add(model); } From c30d45dc426343fe60aff2bbf8e94d999cbc3adb Mon Sep 17 00:00:00 2001 From: Codebdbd Date: Tue, 21 Jul 2026 05:51:23 +0300 Subject: [PATCH 12/19] Complete TextProcessing utility implementation (fixes, UI updates, tests --- AiteBar.Tests/TextProcessingViewModelTests.cs | 3 +- AiteBar/Resources/Strings.de.resx | 2 + AiteBar/Resources/Strings.resx | 2 + AiteBar/Resources/Strings.ru.resx | 2 + AiteBar/Resources/Strings.uk.resx | 2 + AiteBar/TextProcessingService.cs | 3 +- AiteBar/TextProcessingWindow.xaml | 32 ++++----------- AiteBar/TextProcessingWindow.xaml.cs | 41 +++++++++++-------- AiteBar/UtilityButtonCatalog.cs | 2 +- 9 files changed, 46 insertions(+), 43 deletions(-) diff --git a/AiteBar.Tests/TextProcessingViewModelTests.cs b/AiteBar.Tests/TextProcessingViewModelTests.cs index 5391b9a..bcfc114 100644 --- a/AiteBar.Tests/TextProcessingViewModelTests.cs +++ b/AiteBar.Tests/TextProcessingViewModelTests.cs @@ -57,8 +57,9 @@ public void SwitchMode_ChangesCurrentMode() public void SwitchMode_DuringProcessing_Ignored() { var vm = CreateViewModel(); + vm.IsProcessing = true; vm.SwitchMode(TextProcessingMode.Cleanup); - Assert.Equal(TextProcessingMode.Cleanup, vm.CurrentMode); + Assert.Equal(TextProcessingMode.Proofread, vm.CurrentMode); } [Fact] diff --git a/AiteBar/Resources/Strings.de.resx b/AiteBar/Resources/Strings.de.resx index 63f043d..eb9bd5d 100644 --- a/AiteBar/Resources/Strings.de.resx +++ b/AiteBar/Resources/Strings.de.resx @@ -673,6 +673,8 @@ GitHub-Release-Seite öffnen? Wiederholen Original anzeigen Ergebnis anzeigen + Vorher + Nachher Löschen Abbrechen Text eingeben oder aus der Zwischenablage einfügen diff --git a/AiteBar/Resources/Strings.resx b/AiteBar/Resources/Strings.resx index 3c37409..eede83f 100644 --- a/AiteBar/Resources/Strings.resx +++ b/AiteBar/Resources/Strings.resx @@ -730,6 +730,8 @@ Open the GitHub release page? Repeat Show original Show result + Before + After Clear Cancel Enter text or paste from clipboard diff --git a/AiteBar/Resources/Strings.ru.resx b/AiteBar/Resources/Strings.ru.resx index c4505bc..15aed1e 100644 --- a/AiteBar/Resources/Strings.ru.resx +++ b/AiteBar/Resources/Strings.ru.resx @@ -1250,6 +1250,8 @@ cd /d "B:\project_name" && py -m app.main Повторить Показать исходный Показать результат + До обработки + После обработки Очистить Отменить Введите текст или вставьте его из буфера обмена diff --git a/AiteBar/Resources/Strings.uk.resx b/AiteBar/Resources/Strings.uk.resx index 8ada902..8479ea3 100644 --- a/AiteBar/Resources/Strings.uk.resx +++ b/AiteBar/Resources/Strings.uk.resx @@ -667,6 +667,8 @@ cd /d "B:\project_name" && py -m app.main Повторити Показати оригінал Показати результат + До обробки + Після обробки Очистити Скасувати Введіть текст або вставте його з буфера обміну diff --git a/AiteBar/TextProcessingService.cs b/AiteBar/TextProcessingService.cs index 3d163d0..bbe0251 100644 --- a/AiteBar/TextProcessingService.cs +++ b/AiteBar/TextProcessingService.cs @@ -142,8 +142,7 @@ private static string StripServiceLine(string text) "Ergebnis:", "Виправлений текст:", "Оформлений текст:", - "Очищений текст:", - "Результат:" + "Очищений текст:" ]; foreach (string prefix in prefixes) diff --git a/AiteBar/TextProcessingWindow.xaml b/AiteBar/TextProcessingWindow.xaml index e9d0d7f..f2c2bde 100644 --- a/AiteBar/TextProcessingWindow.xaml +++ b/AiteBar/TextProcessingWindow.xaml @@ -296,12 +296,12 @@ - + - + - - - - + - +