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

178 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
Конвенции этого репозитория и грабли, на которые здесь уже наступили. Читать до правок.
## Команды
Полный локальный гейт (формат → сборка → тесты) — то, что нужно прогнать перед коммитом:
```bash
./build.ps1
```
Отдельные шаги, если нужен только один из них:
```bash
dotnet build AvParser.slnx -c Release
```
```bash
dotnet test AvParser.slnx -c Release
```
```bash
dotnet csharpier check .
```
```bash
./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`.