The scaffolding domain existed to prove the shell end to end before there was
anything real to put in it. There is now, so it goes - as CLAUDE.md promised it
would.
Gone: the two sample parsers, ITextParser, ParsedRecord, the parser catalog,
ParseViewModel and ParseView, their tests, and the settings key that remembered
which parser was last used. ParseError.LineNumber becomes Index, since for a
listing "line 42" was simply untrue, and the error keys move from Parse.Error.*
to Collect.Error.* now that parsing is not a concept here.
Kept: IParser<,>, ParseOutcome, ParseProgress and ParseError. The streaming
contract was always the general part - it was only ever the text-shaped closure
of it that was scaffolding.
Rendering the dashboard caught two keys that were referenced but never added
during the rename: the XAML was repointed and the resources were not. The parity
test could not see it, because it compares the two files against each other and
a key absent from both is consistent. That gap now has its own test, which reads
every {l:Loc} in the XAML and checks it resolves - a screenshot is too late and
too manual a way to find a missing string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
22 KiB
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. Логика туда не переезжает.
Добавить источник
- Реализовать
IMediaSource— вCore/Collecting/Sources/, если сети не нужно, иначе вInfrastructure/Collecting/. - Одна строка регистрации:
AddAvParserCore()для доменного,AddAvParserCollecting()для сетевого. - Два ключа в оба resx:
Source.{id}.NameиSource.{id}.Description(без них источник отрисуется английским текстом из самого класса, а не сломается).
Всё. Каталог, страница «Сбор» и выпадающий список подхватят его сами.
Источник ищет, а не качает. Он отдаёт MediaCandidate — адрес плюс метаданные. Скачиванием,
редиректами, тайм-аутами, сниффингом и троттлингом занимается MediaFetcher, один на всех. Источник,
который сам лезет за байтами, дублирует всё это и почти наверняка неправильно.
Сетевой источник обязан переопределить RequiresNetwork => true — иначе он не попадёт под гейт
и поедет напрямую в обход настройки.
Добавить страницу
- Наследник
PageViewModelвUI/ViewModels/XxxViewModel.cs(Title,IconKey). UI/Views/XxxView.axaml— имя обязано соответствовать конвенцииViewLocator:...ViewModels.XxxViewModel→...Views.XxxView.- Регистрация в
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.
Добавить источник прокси
- Реализовать
IProxySource(илиIMutableProxySource, если список редактируемый). - Зарегистрировать как
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.LiveCountсчитает толькоAliveи не в карантине. На нём висит гейт сбора, поэтому «доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться от одного лишь истечения окна.- Прогрев обрывается по достижении цели, а не проходит список до конца.
WarmUpAsyncлинкует CTS и гасит остаток, как только набралосьMinimumLiveProxies. Порядок кандидатов —WarmUpOrder(), он публичный ровно затем, чтобы порядок проверялся без прогона проб. RestoreStateне выставляетHealth = Alive. «Работала вчера» живёт в отдельномWasAliveOnLastRunи влияет только на порядок прогрева. Если восстанавливать какAlive, пул отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит никого, а гейт сбора откроется по данным недельной давности. ЛовитA_remembered_proxy_is_not_reported_live_until_it_answers_again.RestoreStateне восстанавливает карантин. Окно — стенные часы, между запусками могли пройти сутки; перенос окна сажал бы прокси за то, что давно истекло.- Сохраняются только
HasEverAnswered=SuccessCount > 0 || IsBelievedAlive, где «believed» = вердикт этой сессии, а без него — прошлой. Мёртвые в фиде исчисляются тысячами и переиздаются каждые пять минут. Важен именно перенос: прогрев обрывается рано, поэтому большинство запомненных заканчивают сессию непроверенными — строгоеHealth == Aliveстирало бы накопленный список за пару запусков.
Гейт сбора
IParser.RequiresNetwork — дефолтная реализация возвращает false, поэтому добавление источника
остаётся однострочным.
Гейт живёт в CollectViewModel.RefreshProxyGate() и складывается из трёх условий: источник сетевой,
AllowDirectConnection выключен, LiveCount == 0. Он пересчитывается по событию пула (с
throttle 250 мс — пул дёргается на каждый исход лизы), при смене источника и при смене настроек.
Второй экземпляр того же правила — в FetchOptions.RequireProxy: гейт закрывает кнопку, а фетчер
бросает ProxyUnavailableException вместо тихого прямого запроса. Одного UI мало — запрос ушёл бы
с адреса пользователя ровно тогда, когда он просил этого не делать.
CollectView.axaml держит баннер под x:Name="ProxyGateBanner"; CollectViewTests рендерит его
по-настоящему, потому что мёртвый биндинг IsVisible не ломает ни одного VM-теста.
Инварианты хранилища медиа
- В
blobs/попадает только дочитанное. Загрузка идёт во временный файл в соседнем каталоге на том же томе и продвигается переименованием. Обрыв оставляет.part, который подметает следующий старт, а не обрезанную картинку, неотличимую от настоящей навсегда. - Тип — по сигнатуре, никогда по URL, расширению или
Content-Type. Два из трёх выбирает тот, кто отдаёт файл, и расширение на диске у пользователя не должно зависеть от чужого сервера. GIF89aне доказывает анимацию, и APNG не определяется по фиксированному префиксу: нужен обход блоков (второй Image Descriptor) и чанков (acTLраньше первогоIDAT). Тихая ошибка, поэтому обходчики изолированы заinternal staticшвами и проверяются на массивах байтов.ref_countденормализован и пересчитывается, а не инкрементится. Апсертitemможет заменить строку, указывавшую на другой blob, и слепой+1уехал бы навсегда. ЕстьVerifyReferenceCountsAsync, и он часть замысла, а не отладка.- Журнал
seen_urlпереживает чистку. Иначе следующий прогон скачает заново ровно то, что пользователь только что удалил. Терминальные исходы отделены от повторяемых: отказ описывает момент, а не ресурс, и считать его окончательным значит терять контент на каждой сетевой икоте. - Вердикт лизы — про транспорт, а не про ресурс. 404 и 429 — это успех прокси. Иначе пул карантинил бы рабочие адреса ровно с той частотой, с какой встречаются мёртвые ссылки, а на лимит отвечал бы сменой прокси, то есть обходом лимита.
- Троттл поднимается только сигналами хоста (429/503 с
Retry-After) и никогда не приводит к ротации прокси. Флажка «повторить через другую прокси при 429» в настройках быть не должно. - Жёсткая ссылка — привилегия ФС, а не гарантия. Откат на копию удваивает расход диска, поэтому
достигнутый режим пишется в
showcase_modeи виден в UI. - Имя из
SuggestedNameвраждебно. Остаётся только последний сегмент, разделители не переживают, устройства Windows отодвигаются, расширение берётся из типа.
Добавить строку в UI
- Ключ и оба перевода — в
Strings.resxиStrings.ru.resx(оба, иначе упадётLocalizationTests.Russian_translates_every_english_key). - В XAML —
{l:Loc Ключ}, во ViewModel —Localizer.Instance[...]/.Format(...).
Никакого хардкода в Views/ кроме имени продукта и примеров адресов.
- Счётчики — только через
Localizer.Pluralс ключами.One/.Few/.Many. У русского три формы; «{0} records» с приклеенным окончанием непереводимо. - Перечисления в списках —
LocalizedOption<T>, не сырые значения. Конвертер разрешил бы подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы выбор не слетал. - Текст из домена переводится по коду.
Coreо языках не знает:ParseErrorнесётCodeиArguments, UI ищетCollect.Error.{Code}с откатом наMessage. Имена источников — так же:Source.{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.XUnit12.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, не в самих тестах.
Границы, выбранные намеренно
- robots.txt не читается. Оба источника v1 либо принадлежат пользователю, либо введены им вручную, поэтому спрашивать разрешения не у кого. Это решение, а не забывчивость: с появлением третьего источника, который ходит по чужому сайту, robots.txt становится обязательным.
- Перебора идентификаторов нет и не планируется. Источник перечисляет то, что сайт сам публикует; подбор адресов — это не «сбор опубликованного», и прокси-пул существует ради лимитов и доступности, а не ради их обхода.
- Возобновления по
Rangeнет. Оборванная загрузка выбрасывается целиком; частичный файл в хранилище дороже, чем повторное скачивание. - SVG не поддерживается сознательно — это текст, он умеет исполнять скрипты и несёт XXE. BMP/ICO/HEIC/JPEG-XL просто отложены.
- Перцептивных хешей нет. Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача.