Strings move into Localization/Strings.resx plus a Russian satellite. XAML uses
a {l:Loc Key} markup extension that yields a binding through the localizer's
indexer rather than a resolved string, so changing the language raises
PropertyChanged for the indexer and every caption in the app re-reads at once.
Resolving strings once at load would have been simpler and would have needed an
app restart to take effect.
Three places where this is more than a string swap:
Russian has three plural forms, so counted messages are assembled from .One /
.Few / .Many keys via Localizer.Plural instead of "{0} records" with an English
plural glued on. "1 запись", "3 записи", "7 записей".
Enum captions in pickers go through LocalizedOption<T>. A value converter would
resolve the caption once and never notice a language change; the wrapper keeps
identity on the enum value so the selection survives, while the label follows
the localizer. It needed a non-generic base for DataTemplate x:DataType, since
Avalonia 12 compiles bindings by default and cannot infer one for an open
generic.
Text originating in the domain would otherwise have stayed English under a
Russian UI — the screenshots showed exactly that. AvParser.Core still knows
nothing about languages: ParseError now carries a Code and Arguments, and the UI
translates Parse.Error.{Code} with a fallback to the English message. Parser
names work the same way (Parser.{id}.Name falling back to DisplayName), which
keeps "add a parser = one registration line" true — an untranslated parser shows
its own name rather than a missing-key marker.
Both .resx files are generated from one table so a key cannot exist in one and
be missing from the other, and the tests assert that, plus no blank translations
and identical {0} placeholder sets — a translation that drops a placeholder
throws at runtime rather than merely reading oddly. A headless test switches
language on a live shell and asserts the rendered text changes without the tree
being rebuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AvParser
Каркас desktop-приложения на Avalonia 12 с ReactiveUI-MVVM, адаптивным layout поверх Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов.
Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт
IParser<TInput, TOutput> и два демо-парсера, чтобы каркас был запускаемым и проверяемым
end-to-end до появления настоящей логики.
Быстрый старт
Нужен .NET SDK 10.0.100 (закреплён в global.json).
Полный локальный гейт — восстановление, проверка форматирования, сборка, тесты:
./build.ps1
Запуск приложения (по умолчанию Debug):
./run.ps1
На Linux и macOS — ./build.sh и ./run.sh, аргументы те же.
Полезные флаги:
./build.ps1 -Fix -Configuration Debug
-Fix переформатирует код вместо того, чтобы падать на непрошедшей проверке; -SkipTests
собирает без прогона тестов. То же в bash: --fix, -c Debug, --skip-tests.
Если нужны отдельные шаги, скрипты ничего не прячут:
dotnet build AvParser.slnx -c Release
dotnet test AvParser.slnx -c Release
dotnet csharpier check .
Структура
src/
AvParser.Core домен: IParser, IParserCatalog, модели, демо-парсеры
ноль зависимостей кроме DI.Abstractions — ни Avalonia, ни IO
AvParser.Infrastructure AppPaths, JSON-настройки с debounce, Serilog
AvParser.UI Avalonia class library: App-независимые View, ViewModel,
ResponsiveLayout, дизайн-токены, навигация
AvParser.Desktop WinExe-хост: Program.cs, App.axaml, composition root
tests/
AvParser.Core.Tests парсеры, реестр, отмена, прогресс, пул прокси и стратегии
AvParser.Infrastructure.Tests разбор фида прокси, локальный список, маппинг на WebProxy
AvParser.UI.Tests ViewModel'и без Avalonia
AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact]
Ссылки идут строго в одну сторону: Core ← Infrastructure ← UI ← Desktop.
UI — библиотека, а не exe, именно чтобы headless-тесты собирали настоящие View, не подтягивая
Program.cs, Serilog и контейнер.
Адаптивный layout
В Avalonia нет AdaptiveTrigger, VisualStateManager и media-queries. Есть три примитива:
наблюдаемый Visual.Bounds, псевдоклассы и SplitView. ResponsiveLayout связывает первое со
вторым — получается CSS-подобная реакция на ширину.
| Брейкпоинт | Ширина окна | Навигация |
|---|---|---|
| Compact | < 720 px | выезжающий drawer поверх контента |
| Medium | 720 – 1100 px | рельс из одних иконок (56 px) |
| Expanded | ≥ 1100 px | полный сайдбар с подписями (248 px) |
Переключение с гистерезисом в 24 px: без неё перетаскивание края окна заставляет layout мигать между двумя состояниями на каждом пикселе дрожания.
Разделение обязанностей, которое важно не сломать:
SplitView.DisplayModeиIsPaneOpenбиндятся во ViewModel. Style-сеттер навсегда проигрывает локальному значению, поэтому первый же клик по гамбургеру заморозил бы любой стиль, который тоже пишет в эти свойства.- Всё чисто визуальное — ширины панели, видимость подписей, паддинги — живёт в
Styles/Shell.axaml.
Селекторы там написаны как :is(UserControl).shell, а не UserControl.shell: селектор типа в
Avalonia матчит точный тип, а ShellView наследуется от ReactiveUserControl<T> — обычная
форма молча не сматчилась бы ни с чем. На это есть тест
(ShellViewTests.The_shell_stylesheet_is_actually_applied).
Прокси
Пул прокси с ротацией — AvParser.Core/Proxies, источники и сетевая часть —
AvParser.Infrastructure/Proxies, управление — страница Proxies.
Источники:
- proxifly/free-proxy-list — публичный список,
обновляется каждые 5 минут. Тянем сводный
all/data.jsonчерез jsDelivr и фильтруем локально: один условный запрос за весь список надёжнее четырёх по протоколам, которые могут разъехаться между собой в момент публикации. Ответ кэшируется на 5 минут, недоступность фида не роняет приложение — остаётся прошлый список. - Свой список —
proxies.custom.jsonрядом с настройками. Вставляется пачкой, по одной на строку; поддерживаютсяscheme://host:port, голыйhost:portиuser:pass@. Непонятые строки не проглатываются молча, а называются в статусе.
Ротация выбирается в настройках:
| Стратегия | Поведение | Когда |
|---|---|---|
| Sticky | одна прокси, смена только по отказу | по умолчанию: не рвёт сессии и cookie |
| RoundRobin | новая на каждый запрос | размазывает рейт-лимиты, но ломает сессии |
| WeightedRandom | случайно, с весом по score и доле успехов | при сильном разбросе качества |
Проверка живости — тоже настройка, два режима: Pool прогоняет весь список параллельно один раз, Lazy проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков рабочих обычно единицы процентов, поэтому без проверки парсер будет в основном ждать таймауты.
Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но не удаляется навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно.
Использование из кода:
var (http, lease) = await clientFactory.CreateFromPoolAsync();
using (http)
using (lease)
{
try { var response = await http.GetAsync(url); lease?.ReportSuccess(); }
catch { lease?.ReportFailure("request failed"); throw; }
}
Отчёт об исходе — не формальность: без него пул ничего не узнаёт о том, какие прокси работают. Освобождение лизы без вердикта нейтрально — отменённая операция не вина прокси.
SOCKS работает штатно: .NET понимает схемы socks4/socks4a/socks5 в WebProxy. Учтите, что
proxifly-запись с "protocol": "https" — это всё равно HTTP-прокси с CONNECT, а не схема https://.
Локализация
Русский и английский, переключение без перезапуска — язык выбирается в настройках («Системный» берёт язык ОС, если для него есть перевод, иначе английский).
Строки лежат в UI/Localization/Strings.resx и Strings.ru.resx; русский собирается в
сателлитную сборку ru/AvParser.UI.resources.dll.
В XAML — разметочное расширение:
<TextBlock Text="{l:Loc Parse.Run}" />
Оно возвращает биндинг через индексатор Localizer, а не готовую строку: смена языка
поднимает PropertyChanged для индексатора, и все такие биндинги перечитываются разом. Строка,
разрешённая один раз при загрузке, потребовала бы перезапуска.
Три места, где локализация упирается в грамматику или в слои:
- Множественные числа. У русского три формы, поэтому счётчики собираются не из «{0} records»
с приклеенным окончанием, а из ключей
.One/.Few/.ManyчерезLocalizer.Plural. «1 запись», «3 записи», «7 записей». - Значения перечислений. Конвертер разрешил бы подпись один раз и не заметил смены языка,
поэтому в списках лежат обёртки
LocalizedOption<T>: идентичность — значение перечисления (выбор не слетает), подпись следует за локализатором. - Текст из домена.
AvParser.Coreо языках не знает. Парсеры отдают английское сообщение и код, а UI переводитParse.Error.{Code}с откатом на сообщение. Так же и с именами парсеров:Parser.{id}.Nameс откатом наDisplayName, поэтому новый парсер работает непереведённым, а не показывает!ключ!.
Оба .resx генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и
отсутствовать в другом; тесты проверяют совпадение ключей, отсутствие пустых переводов и
одинаковый набор плейсхолдеров {0}.
Дизайн-токены
Все цвета, отступы, радиусы и типографика — в Styles/Tokens.axaml, с отдельными словарями
для Light и Dark. В остальном XAML нет ни одного литерального цвета и ни одного «магического»
отступа, так что перекрасить тему или уплотнить интерфейс — это правка одного файла.
Semi.Avalonia даёт темы контролов; токены — это семантический слой приложения поверх них.
Кнопки .primary / .destructive описаны своими стилями, а не классами Semi, чтобы акцентный
цвет не разъезжался между двумя палитрами.
Стек
| Пакет | Версия | Заметка |
|---|---|---|
| Avalonia | 12.1.1 | compiled bindings по умолчанию → x:DataType обязателен |
| ReactiveUI.Avalonia | 12.1.1 | Avalonia.ReactiveUI — deprecated, это его преемник |
| ReactiveUI | 24.1.0 | дистрибутив Primitives: RxVoid вместо Unit, ISequencer вместо IScheduler |
| Semi.Avalonia | 12.1.0.1 | темы контролов |
| xUnit | v3 (3.2.2) | Avalonia.Headless.XUnit 12.x требует именно v3 |
Что проверить руками
- Потянуть окно по ширине — сайдбар проходит путь
полный → только иконки → выезжающий drawer, без мигания на границах. - Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился.
- На странице Parse нажать 50k rows, затем Parse — виден прогресс; Cancel
останавливает на середине и пишет, сколько успело разобраться.
В Avalonia 12 инспектора «из коробки» больше нет:
Avalonia.Diagnosticsостановился на 11.3.x, а DevTools вынесли в отдельный инструмент со своей установкой (AvaloniaUI.DiagnosticsSupport+.WithDeveloperTools()). ПоэтомуF12здесь ничего не открывает — зависимость намеренно не добавлена.
Настройки и логи лежат в %APPDATA%/AvParser (Windows) или ~/.config/AvParser (Linux/macOS).