Files
av-parser/CLAUDE.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

13 KiB
Raw Blame History

CLAUDE.md

Конвенции этого репозитория и грабли, на которые здесь уже наступили. Читать до правок.

Команды

Полный локальный гейт (формат → сборка → тесты) — то, что нужно прогнать перед коммитом:

./build.ps1

Отдельные шаги, если нужен только один из них:

dotnet build AvParser.slnx -c Release
dotnet test AvParser.slnx -c Release
dotnet csharpier check .
./run.ps1

На Linux/macOS — ./build.sh и ./run.sh с теми же шагами.

Слои

Core ← Infrastructure ← UI ← Desktop, строго в одну сторону.

  • AvParser.Core не ссылается на Avalonia. Это единственное ограничение, которое здесь по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как только Avalonia станет доступна из домена, кто-нибудь потянется к Dispatcher.UIThread или IStorageProvider внутри парсера.
  • AvParser.UI — библиотека, а не exe. Headless-тесты собирают настоящие View, не подтягивая Program.cs, Serilog и контейнер.
  • AvParser.Desktop — тонкий composition root. Логика туда не переезжает.

Добавить парсер

  1. Реализовать ITextParser в Core/Parsing/.
  2. Одна строка в CoreServiceCollectionExtensions.AddAvParserCore().

Всё. IParserCatalog, страница Parse и выпадающий список подхватят его сами.

Добавить страницу

  1. Наследник PageViewModel в UI/ViewModels/XxxViewModel.cs (Title, IconKey).
  2. UI/Views/XxxView.axaml — имя обязано соответствовать конвенции ViewLocator: ...ViewModels.XxxViewModel...Views.XxxView.
  3. Регистрация в AddAvParserUI(): конкретным типом и как PageViewModel — порядок этих регистраций и есть порядок пунктов в рельсе навигации.

ReactiveUI 24 (дистрибутив Primitives)

Это не классический ReactiveUI. System.Reactive не используется:

Классика Здесь
Unit RxVoid
IScheduler ISequencer (ReactiveUI.Primitives.Concurrency)
Subject<T> / BehaviorSubject<T> Signal<T> / BehaviorSignal<T>
RxApp.MainThreadScheduler RxSchedulers.MainThreadScheduler
TestScheduler VirtualClock, ImmediateSequencer.Instance

Привычные имена операторов (Select, Where, Throttle, DistinctUntilChanged, CombineLatest) работают — Primitives отдаёт оба набора. using ReactiveUI.Primitives; нужен ради Subscribe(Action<T>).

ReactiveUI 24 не инициализируется сама. Первый WhenAnyValue бросит InvalidOperationException, пока не отработал builder. В приложении это делает AppBuilder.UseReactiveUI(...); в проекте VM-тестов — module initializer ReactiveUiBootstrap. Новый тестовый проект без Avalonia обязан сделать то же самое.

Конвенции ViewModel

  • Каждая VM принимает ISequencer? mainThread = null и использует его в outputScheduler: и ToProperty(..., scheduler). Именно это делает тесты синхронными: они передают ImmediateSequencer.Instance. Без этого пришлось бы гонять диспетчер.
  • У VM с необязательным ISequencer регистрация в DI — явная фабрика, а не по типу: иначе выбор конструктора контейнером зависит от порядка регистраций.
  • [Reactive] из ReactiveUI.SourceGenerators на partial-свойствах; класс — partial.

Добавить источник прокси

  1. Реализовать IProxySource (или IMutableProxySource, если список редактируемый).
  2. Зарегистрировать как IProxySource в AddAvParserProxies(). Порядок регистрации = порядок слияния; свой список идёт последним, чтобы пользовательский адрес перебивал фидовый.

ProxyPool при обновлении переиспользует существующие ProxyEntry по Endpoint.Key — иначе перезагрузка списка стирала бы всю накопленную статистику, а публичные фиды переиздаются каждые несколько минут.

Инварианты прокси-пула

  • Доступность определяется карантином, а не Health. Health — это «что видели в последний раз». Если исключать всё, что когда-либо падало, окно карантина становится бессмысленным, а прокси теряется навсегда после первой же осечки. Это уже был баг, его ловит A_failing_proxy_is_quarantined_and_comes_back_later.
  • Проба не трогает SuccessCount/FailureCount. Эти счётчики про реальные запросы; свип по паре тысяч прокси перезаписал бы всё, на чём держится взвешенный выбор. Провалившаяся проба выставляет карантин через RecordProbe(..., quarantineOnFailure:).
  • Лиза без вердикта нейтральна. Отменённая операция — не вина прокси; считать это отказом значит карантинить здоровые прокси на каждый Cancel.
  • Select и Next — зарезервированные слова для CA1716. Метод стратегии называется Pick.

Добавить строку в UI

  1. Ключ и оба перевода — в Strings.resx и Strings.ru.resx (оба, иначе упадёт LocalizationTests.Russian_translates_every_english_key).
  2. В XAML — {l:Loc Ключ}, во ViewModel — Localizer.Instance[...] / .Format(...).

Никакого хардкода в Views/ кроме имени продукта и примеров адресов.

  • Счётчики — только через Localizer.Plural с ключами .One / .Few / .Many. У русского три формы; «{0} records» с приклеенным окончанием непереводимо.
  • Перечисления в списках — LocalizedOption<T>, не сырые значения. Конвертер разрешил бы подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы выбор не слетал.
  • Текст из домена переводится по коду. Core о языках не знает: ParseError несёт Code и Arguments, UI ищет Parse.Error.{Code} с откатом на Message. Имена парсеров — так же: Parser.{id}.Name с откатом на DisplayName, поэтому обещание «добавить парсер = одна строка» остаётся в силе.
  • VM, у которой есть производный от языка текст, переопределяет OnLanguageChanged и зовёт base. Без этого заголовок страницы останется на прежнем языке.

Грабли, уже оплаченные

  • Селектор типа в Avalonia матчит точный тип. UserControl.shell не матчит ShellView (наследник ReactiveUserControl<T>) и молча не делает ничего. Использовать :is(UserControl).shell. Голый .shell тоже матчит, но тогда XAML-компилятор не может вывести тип для Setter и падает с AVLN2200.
  • Style-сеттер навсегда проигрывает локальному значению. Не стилизовать IsPaneOpen и DisplayMode — они биндятся во ViewModel.
  • IPseudoClasses.Set требует ведущего :.
  • InvariantGlobalization обязан быть false: Semi.Avalonia строит CultureInfo в статическом конструкторе и падает целиком.
  • Превьюер рефлексирует безпараметровый статический BuildAvaloniaApp(). Необязательный параметр ломает его вызов, вторая перегрузка — AmbiguousMatchException.
  • Compiled bindings включены по умолчанию (Avalonia 12): x:DataType нужен на каждом UserControl и каждом DataTemplate.
  • Avalonia.Headless.XUnit 12.x — это xUnit v3, а не v2. Тестовые проекты — Exe.
  • Headless: ручной Measure/Arrange внутри окна бесполезен — следующий проход layout окна вернёт свой размер. Задавать ширину самому Window. Но и без окна нельзя: у открепленного контрола не строится визуальное дерево.
  • csharpier — единственный владелец форматирования (включая .axaml и .csproj). IDE0055 понижен до suggestion: два форматтера с TreatWarningsAsErrors дерутся насмерть.
  • Condition="'$(Configuration)' == 'Debug'" на PackageReference — ловушка. dotnet restore вычисляется с конфигурацией по умолчанию, поэтому пакет обязан резолвиться, даже если эту конфигурацию никто не собирает. Так тут проехал мёртвый Avalonia.Diagnostics (его нет под Avalonia 12): dotnet build -c Release работал, а голый dotnet restore падал.
  • Execute() завершился ≠ IsExecuting уже false. Второе публикуется на выходном планировщике. Тест, который сразу после await дёргает команду, закрытую по чужому IsExecuting, будет мигать под нагрузкой — ждите CanExecute, а не предполагайте.
  • Проект VM-тестов не параллелится. ReactiveUiBootstrap ставит глобальные планировщики ReactiveUI, то есть тесты делят изменяемое состояние независимо от их желания.
  • Инспектора в Avalonia 12 нет из коробки. Avalonia.Diagnostics закончился на 11.3.x; DevTools живут отдельно (AvaloniaUI.DiagnosticsSupport + .WithDeveloperTools()), со своей установкой. Зависимость намеренно не добавлена.

Качество

  • Центральные версии пакетов — Directory.Packages.props. Никаких Version= в csproj (иначе NU1008 на restore).
  • TreatWarningsAsErrors включён; NuGet-advisory (NU19xx) выведены из ошибок, чтобы свежая CVE не роняла сборку кода, который никто не трогал.
  • Тестовые послабления анализаторов — в tests/Directory.Build.props, не в самих тестах.

Что осталось абстрактным

Домен — заглушка. IParser<TInput, TOutput> + DelimitedTextParser + KeyValueTextParser существуют, чтобы каркас проверялся end-to-end. Когда появится настоящая доменная логика, демо-парсеры удаляются вместе с их тестами и SampleFor/LargeSampleFor в ParseViewModel.