Files
av-parser/README.md
T
Leonid PershinandClaude Opus 5 8b552470b7 Localise the UI into Russian and English, switchable without a restart
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>
2026-08-13 18:15:01 +03:00

14 KiB
Raw Blame History

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

Что проверить руками

  1. Потянуть окно по ширине — сайдбар проходит путь полный → только иконки → выезжающий drawer, без мигания на границах.
  2. Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился.
  3. На странице Parse нажать 50k rows, затем Parse — виден прогресс; Cancel останавливает на середине и пишет, сколько успело разобраться. В Avalonia 12 инспектора «из коробки» больше нет: Avalonia.Diagnostics остановился на 11.3.x, а DevTools вынесли в отдельный инструмент со своей установкой (AvaloniaUI.DiagnosticsSupport + .WithDeveloperTools()). Поэтому F12 здесь ничего не открывает — зависимость намеренно не добавлена.

Настройки и логи лежат в %APPDATA%/AvParser (Windows) или ~/.config/AvParser (Linux/macOS).