diff --git a/CLAUDE.md b/CLAUDE.md index 2057afd..c5ab78e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,17 +34,28 @@ dotnet csharpier check . - **`AvParser.Core` не ссылается на Avalonia.** Это единственное ограничение, которое здесь по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как только Avalonia станет доступна из домена, кто-нибудь потянется к `Dispatcher.UIThread` или - `IStorageProvider` внутри парсера. + `IStorageProvider` внутри источника. - **`AvParser.UI` — библиотека, а не exe.** Headless-тесты собирают настоящие View, не подтягивая `Program.cs`, Serilog и контейнер. - **`AvParser.Desktop` — тонкий composition root.** Логика туда не переезжает. -## Добавить парсер +## Добавить источник -1. Реализовать `ITextParser` в `Core/Parsing/`. -2. Одна строка в `CoreServiceCollectionExtensions.AddAvParserCore()`. +1. Реализовать `IMediaSource` — в `Core/Collecting/Sources/`, если сети не нужно, иначе в + `Infrastructure/Collecting/`. +2. Одна строка регистрации: `AddAvParserCore()` для доменного, `AddAvParserCollecting()` для + сетевого. +3. Два ключа в оба resx: `Source.{id}.Name` и `Source.{id}.Description` (без них источник + отрисуется английским текстом из самого класса, а не сломается). -Всё. `IParserCatalog`, страница Parse и выпадающий список подхватят его сами. +Всё. Каталог, страница «Сбор» и выпадающий список подхватят его сами. + +**Источник ищет, а не качает.** Он отдаёт `MediaCandidate` — адрес плюс метаданные. Скачиванием, +редиректами, тайм-аутами, сниффингом и троттлингом занимается `MediaFetcher`, один на всех. Источник, +который сам лезет за байтами, дублирует всё это и почти наверняка неправильно. + +**Сетевой источник обязан переопределить `RequiresNetwork => true`** — иначе он не попадёт под гейт +и поедет напрямую в обход настройки. ## Добавить страницу @@ -106,7 +117,7 @@ dotnet csharpier check . - **Лиза без вердикта нейтральна.** Отменённая операция — не вина прокси; считать это отказом значит карантинить здоровые прокси на каждый Cancel. - **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`. -- **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт парсера, поэтому +- **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт сбора, поэтому «доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться от одного лишь истечения окна. - **Прогрев обрывается по достижении цели, а не проходит список до конца.** `WarmUpAsync` линкует @@ -115,7 +126,7 @@ dotnet csharpier check . - **`RestoreState` не выставляет `Health = Alive`.** «Работала вчера» живёт в отдельном `WasAliveOnLastRun` и влияет только на порядок прогрева. Если восстанавливать как `Alive`, пул отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит - никого, а гейт парсера откроется по данным недельной давности. Ловит + никого, а гейт сбора откроется по данным недельной давности. Ловит `A_remembered_proxy_is_not_reported_live_until_it_answers_again`. - **`RestoreState` не восстанавливает карантин.** Окно — стенные часы, между запусками могли пройти сутки; перенос окна сажал бы прокси за то, что давно истекло. @@ -125,19 +136,48 @@ dotnet csharpier check . большинство запомненных заканчивают сессию непроверенными — строгое `Health == Alive` стирало бы накопленный список за пару запусков. -## Гейт парсера +## Гейт сбора -`IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление парсера -остаётся однострочным. Парсер, который куда-то ходит, **обязан** её переопределить, иначе поедет -напрямую в обход настройки. +`IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление источника +остаётся однострочным. -Гейт живёт в `ParseViewModel.RefreshProxyGate()` и складывается из трёх условий: парсер сетевой, +Гейт живёт в `CollectViewModel.RefreshProxyGate()` и складывается из трёх условий: источник сетевой, `AllowDirectConnection` выключен, `LiveCount == 0`. Он пересчитывается по событию пула (с -throttle 250 мс — пул дёргается на каждый исход лизы), при смене парсера и при смене настроек. +throttle 250 мс — пул дёргается на каждый исход лизы), при смене источника и при смене настроек. -`ParseView.axaml` держит баннер под `x:Name="ProxyGateBanner"`; `ParseViewTests` рендерит его +Второй экземпляр того же правила — в `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 1. Ключ и оба перевода — в `Strings.resx` и `Strings.ru.resx` (**оба**, иначе упадёт @@ -152,8 +192,8 @@ throttle 250 мс — пул дёргается на каждый исход л подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы выбор не слетал. - **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и - `Arguments`, UI ищет `Parse.Error.{Code}` с откатом на `Message`. Имена парсеров — так же: - `Parser.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить парсер = одна строка» + `Arguments`, UI ищет `Collect.Error.{Code}` с откатом на `Message`. Имена источников — так же: + `Source.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить источник = одна строка» остаётся в силе. - **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт `base`. Без этого заголовок страницы останется на прежнем языке. @@ -201,8 +241,16 @@ throttle 250 мс — пул дёргается на каждый исход л свежая CVE не роняла сборку кода, который никто не трогал. - Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах. -## Что осталось абстрактным +## Границы, выбранные намеренно -Домен — заглушка. `IParser` + `DelimitedTextParser` + `KeyValueTextParser` -существуют, чтобы каркас проверялся end-to-end. Когда появится настоящая доменная логика, -демо-парсеры удаляются вместе с их тестами и `SampleFor`/`LargeSampleFor` в `ParseViewModel`. +- **robots.txt не читается.** Оба источника v1 либо принадлежат пользователю, либо введены им + вручную, поэтому спрашивать разрешения не у кого. Это решение, а не забывчивость: **с появлением + третьего источника, который ходит по чужому сайту, robots.txt становится обязательным.** +- **Перебора идентификаторов нет и не планируется.** Источник перечисляет то, что сайт сам + публикует; подбор адресов — это не «сбор опубликованного», и прокси-пул существует ради лимитов + и доступности, а не ради их обхода. +- **Возобновления по `Range` нет.** Оборванная загрузка выбрасывается целиком; частичный файл в + хранилище дороже, чем повторное скачивание. +- **SVG не поддерживается сознательно** — это текст, он умеет исполнять скрипты и несёт XXE. + BMP/ICO/HEIC/JPEG-XL просто отложены. +- **Перцептивных хешей нет.** Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача. diff --git a/README.md b/README.md index 4181007..391da37 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # AvParser -Каркас desktop-приложения на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх -Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов. +Desktop-приложение на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх Semi.Avalonia, +единым DI-контейнером и тремя уровнями тестов. -Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт -`IParser` и два демо-парсера, чтобы каркас был запускаемым и проверяемым -end-to-end до появления настоящей логики. +Собирает изображения и гифки в локальное хранилище: источник перечисляет адреса, загрузчик их +скачивает и проверяет, хранилище дедуплицирует по содержимому и помнит, что уже видело. Запросы +идут через пул прокси с ротацией и проверкой живости. + +Источников два: **список ссылок**, который вы вставляете сами, и **свой сервис** — листинг-эндпоинт +сервиса, который вы держите сами. Перебора идентификаторов чужих хостов нет; см. +[«Границы, выбранные намеренно»](CLAUDE.md). --- @@ -54,15 +58,18 @@ dotnet csharpier check . ``` src/ - AvParser.Core домен: IParser, IParserCatalog, модели, демо-парсеры - ноль зависимостей кроме DI.Abstractions — ни Avalonia, ни IO - AvParser.Infrastructure AppPaths, JSON-настройки с debounce, Serilog + AvParser.Core домен: IMediaSource и каталог, модели медиа, контракт хранилища, + прокси-пул и стратегии. Ноль зависимостей кроме DI.Abstractions — + ни Avalonia, ни HTTP, ни SQLite + AvParser.Infrastructure загрузчик (сниффинг, редиректы, троттл), SQLite-индекс, blob-хранилище, + витрина, источники прокси, AppPaths, JSON-настройки, 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.Core.Tests источники, каталог, модели, пул прокси и стратегии + AvParser.Infrastructure.Tests сигнатуры и анимация, загрузчик против «плохого» сервера, + хранилище и дедуп, фид прокси, настройки AvParser.UI.Tests ViewModel'и без Avalonia AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact] ``` @@ -129,7 +136,7 @@ Avalonia матчит **точный** тип, а `ShellView` наследует Проверка живости — тоже настройка, два режима: **Pool** прогоняет весь список параллельно один раз, **Lazy** проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков -рабочих обычно единицы процентов, поэтому без проверки парсер будет в основном ждать таймауты. +рабочих обычно единицы процентов, поэтому без проверки сборщик будет в основном ждать таймауты. Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но **не** удаляется навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно. @@ -143,7 +150,7 @@ Avalonia матчит **точный** тип, а `ShellView` наследует Запомненное — это подсказка, а не зачёт: восстановленная прокси идёт первой в очередь на проверку, но живой не считается, пока не ответит в этом запуске. Иначе запуск через неделю открывал бы гейт -парсера по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть. +сбора по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть. Состояние лежит в `proxies.state.json` рядом с настройками и пишется после прогрева и на выходе. Сохраняются **только те прокси, что когда-либо отвечали**: мёртвых в фиде тысячи, они переиздаются @@ -152,11 +159,15 @@ Avalonia матчит **точный** тип, а `ShellView` наследует ### Гейт «без прокси не работаем» -Парсер, который объявил `RequiresNetwork`, не запустится, пока в пуле нет ни одной живой прокси: -кнопка «Разобрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies. -Гейт снимается настройкой **«Разрешить сетевым парсерам работать без прокси»**. +Источник, который объявил `RequiresNetwork`, не запустится, пока в пуле нет ни одной живой прокси: +кнопка «Собрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies. +Гейт снимается настройкой **«Разрешить сетевым источникам работать без прокси»**. -Парсеры, читающие вставленный пользователем текст, не блокируются никогда — им нечего +То же правило продублировано в загрузчике: он бросает `ProxyUnavailableException` вместо тихого +прямого запроса. Одного UI мало — запрос ушёл бы с адреса пользователя ровно тогда, когда он просил +этого не делать. + +Источники, читающие вставленный пользователем текст, не блокируются никогда — им нечего маршрутизировать, и блокировка делала бы приложение бесполезным всякий раз, когда публичные списки лежат. @@ -178,6 +189,48 @@ using (lease) SOCKS работает штатно: .NET понимает схемы `socks4/socks4a/socks5` в `WebProxy`. Учтите, что proxifly-запись с `"protocol": "https"` — это всё равно HTTP-прокси с CONNECT, а не схема `https://`. +## Сбор + +Страница **Сбор**: выбрать источник, дать ему работу, запустить. + +- **Список ссылок** — вставьте адреса, по одному в строке. Пустые строки и строки с `#` + игнорируются, непонятые называются в списке ошибок, а не проглатываются. +- **Свой сервис** — адрес листинг-эндпоинта. Принимается либо `{"items":[…],"next":"…"}`, либо + голый массив адресов; элемент может быть строкой или объектом с `url`, `id`, `name`, + `published`, `size`, `tags`. Постранично, пока есть `next`. + +Что происходит с каждым найденным адресом: + +1. **Журнал.** Если прошлый прогон уже закрыл этот адрес — пропуск без единого запроса. Отказы и + тайм-ауты закрытыми не считаются: они описывают момент, а не ресурс. Флажок «Скачать всё + заново» игнорирует журнал. +2. **Загрузка.** Редиректы разбираются вручную (лимит прыжков, отлов петли, отказ на не-http). + Три отдельных тайм-аута: соединение, заголовки и простой между чтениями — один общий был бы либо + слишком мал для тридцати мегабайт, либо бесполезен как признак зависания. +3. **Проверка.** Тип определяется по сигнатуре файла, а не по URL, расширению или `Content-Type`. + Ловятся: страница-ошибка за кодом 200, тело короче заявленного, превышение лимита размера, + трекинг-пиксели, известные заглушки мёртвых ссылок. +4. **Хранилище.** Файл кладётся по SHA-256 содержимого — один и тот же снимок, перезалитый по + десяти адресам, занимает место один раз. Провенанс (откуда, когда, каким прогоном, через какую + прокси) пишется отдельно. + +### Витрина + +`blobs/ab/cd/.png` не годится для просмотра глазами, поэтому рядом строится +`showcase/<источник>/<год>/<месяц>/<день>/0001-имя.png` — жёсткими ссылками, то есть без второй +копии байтов. + +Жёсткая ссылка — это **второе имя того же файла**: правка витрины меняет оригинал, а удаление из +витрины ничего не освобождает, пока не исчезнет последнее имя. На FAT32, сетевых шарах и между +томами жёстких ссылок нет — тогда происходит откат на копию, расход диска удваивается, и +действующий режим виден в настройках. + +### Чистка + +Кнопка на странице сбора удаляет то, что собрал выбранный источник. Файл, на который ссылается и +другой источник, остаётся — ровно за этим в индексе счётчик ссылок. Журнал переживает чистку, иначе +следующий прогон скачал бы заново только что удалённое; забыть и его — отдельный флажок. + ## Локализация Русский и английский, переключение **без перезапуска** — язык выбирается в настройках @@ -204,9 +257,9 @@ proxifly-запись с `"protocol": "https"` — это всё равно HTTP - **Значения перечислений.** Конвертер разрешил бы подпись один раз и не заметил смены языка, поэтому в списках лежат обёртки `LocalizedOption`: идентичность — значение перечисления (выбор не слетает), подпись следует за локализатором. -- **Текст из домена.** `AvParser.Core` о языках не знает. Парсеры отдают английское сообщение - **и код**, а UI переводит `Parse.Error.{Code}` с откатом на сообщение. Так же и с именами - парсеров: `Parser.{id}.Name` с откатом на `DisplayName`, поэтому новый парсер работает +- **Текст из домена.** `AvParser.Core` о языках не знает. Домен отдаёт английское сообщение + **и код**, а UI переводит `Collect.Error.{Code}` с откатом на сообщение. Так же и с именами + источников: `Source.{id}.Name` с откатом на `DisplayName`, поэтому новый источник работает непереведённым, а не показывает `!ключ!`. Оба `.resx` генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и @@ -242,8 +295,14 @@ Semi.Avalonia даёт темы контролов; токены — это се 1. Потянуть окно по ширине — сайдбар проходит путь `полный → только иконки → выезжающий drawer`, без мигания на границах. 2. Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился. -3. На странице Parse нажать **50k rows**, затем **Parse** — виден прогресс; **Cancel** - останавливает на середине и пишет, сколько успело разобраться. +3. На странице **Сбор** вставить десяток адресов и нажать **Собрать**: список наполняется, + **Остановить** обрывает на середине и пишет, сколько успело собраться. +4. Запустить тот же список повторно — все строки должны прийти как «пропущено», без единого + сетевого запроса. Это журнал. +5. Заглянуть в `media/showcase` — файлы разложены по датам; сверить, что это жёсткие ссылки + (`fsutil hardlink list` в Windows, `ls -li` в Linux), а не копии. +6. Нажать чистку — файлы, на которые ссылается только этот источник, исчезают; общие остаются. + В Avalonia 12 инспектора «из коробки» больше нет: `Avalonia.Diagnostics` остановился на 11.3.x, а DevTools вынесли в отдельный инструмент со своей установкой (`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`). Поэтому `F12` здесь ничего не diff --git a/src/AvParser.Core/DependencyInjection/CoreServiceCollectionExtensions.cs b/src/AvParser.Core/DependencyInjection/CoreServiceCollectionExtensions.cs index dc6b5d5..6636fcf 100644 --- a/src/AvParser.Core/DependencyInjection/CoreServiceCollectionExtensions.cs +++ b/src/AvParser.Core/DependencyInjection/CoreServiceCollectionExtensions.cs @@ -1,7 +1,5 @@ using AvParser.Core.Collecting; using AvParser.Core.Collecting.Sources; -using AvParser.Core.Parsing; -using AvParser.Core.Parsing.Samples; using Microsoft.Extensions.DependencyInjection; namespace AvParser.Core.DependencyInjection; @@ -10,20 +8,17 @@ namespace AvParser.Core.DependencyInjection; public static class CoreServiceCollectionExtensions { /// - /// Registers every parser plus the catalog that indexes them. + /// Registers every media source the domain itself provides. /// /// - /// Adding a parser is a one-line change here — that is the whole point of the - /// / split. + /// Adding a source is a one-line change here — that is the whole point of the + /// / split. Sources that need a + /// network live in the infrastructure layer and register themselves there. /// public static IServiceCollection AddAvParserCore(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); return services; diff --git a/src/AvParser.Core/Parsing/IParserCatalog.cs b/src/AvParser.Core/Parsing/IParserCatalog.cs deleted file mode 100644 index 28aee4e..0000000 --- a/src/AvParser.Core/Parsing/IParserCatalog.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace AvParser.Core.Parsing; - -/// Read-only view over every registered text parser. -public interface IParserCatalog -{ - /// All registered parsers, ordered by . - IReadOnlyList Parsers { get; } - - /// The parser used when nothing has been chosen yet. - ITextParser DefaultParser { get; } - - /// Finds a parser by its stable id; when unknown. - ITextParser? Find(string? id); - - /// Finds a parser by id, falling back to . - ITextParser FindOrDefault(string? id) => Find(id) ?? DefaultParser; -} diff --git a/src/AvParser.Core/Parsing/ITextParser.cs b/src/AvParser.Core/Parsing/ITextParser.cs deleted file mode 100644 index 2b71073..0000000 --- a/src/AvParser.Core/Parsing/ITextParser.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AvParser.Core.Parsing; - -/// -/// Closed, non-generic facade over . -/// -/// -/// Open generic interfaces cannot be resolved as IEnumerable<T> by the DI container, -/// so every text-shaped parser implements this closed interface and gets registered under it. -/// -public interface ITextParser : IParser; diff --git a/src/AvParser.Core/Parsing/ParseError.cs b/src/AvParser.Core/Parsing/ParseError.cs index 843ad88..b379d02 100644 --- a/src/AvParser.Core/Parsing/ParseError.cs +++ b/src/AvParser.Core/Parsing/ParseError.cs @@ -1,9 +1,9 @@ namespace AvParser.Core.Parsing; /// A recoverable problem with a single record. Parsing continues after one of these. -/// 1-based position of the offending record in the input. +/// 1-based position of the offending item: a line, or a place in a listing. /// What went wrong, phrased for a user rather than a developer. -public sealed record ParseError(int LineNumber, string Message) +public sealed record ParseError(int Index, string Message) { /// /// Stable identifier for the kind of failure, or when the message is @@ -11,7 +11,7 @@ public sealed record ParseError(int LineNumber, string Message) /// /// /// The domain stays language-free: it produces an English message plus a code, and the UI - /// translates Parse.Error.{Code} with , falling back to + /// translates Collect.Error.{Code} with , falling back to /// . Without this, a Russian UI would still print English error text — /// and moving the strings themselves into the domain would drag localisation down there. /// @@ -21,9 +21,9 @@ public sealed record ParseError(int LineNumber, string Message) public IReadOnlyList Arguments { get; init; } = []; /// Creates an error carrying a translation code. - public static ParseError Create(int lineNumber, string code, string message, params object?[] arguments) => - new(lineNumber, message) { Code = code, Arguments = arguments }; + public static ParseError Create(int index, string code, string message, params object?[] arguments) => + new(index, message) { Code = code, Arguments = arguments }; /// - public override string ToString() => $"Line {LineNumber}: {Message}"; + public override string ToString() => $"#{Index}: {Message}"; } diff --git a/src/AvParser.Core/Parsing/ParsedRecord.cs b/src/AvParser.Core/Parsing/ParsedRecord.cs deleted file mode 100644 index adb5552..0000000 --- a/src/AvParser.Core/Parsing/ParsedRecord.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace AvParser.Core.Parsing; - -/// One named field of a . -/// Column name, or the positional index rendered as text. -/// Raw field value, already trimmed of surrounding whitespace. -public readonly record struct ParsedField(string Name, string Value) -{ - /// - public override string ToString() => $"{Name}={Value}"; -} - -/// A single successfully parsed record. -/// 1-based position of the record in the source input. -/// The record's fields, in source order. -public sealed record ParsedRecord(int LineNumber, IReadOnlyList Fields) -{ - /// Flattened key=value rendering, used by the results list. - public string Summary => string.Join(" ", Fields); - - /// Looks a field up by name; when absent. - public string? this[string name] - { - get - { - foreach (var field in Fields) - { - if (string.Equals(field.Name, name, StringComparison.OrdinalIgnoreCase)) - { - return field.Value; - } - } - - return null; - } - } -} diff --git a/src/AvParser.Core/Parsing/ParserCatalog.cs b/src/AvParser.Core/Parsing/ParserCatalog.cs deleted file mode 100644 index c311db2..0000000 --- a/src/AvParser.Core/Parsing/ParserCatalog.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace AvParser.Core.Parsing; - -/// -public sealed class ParserCatalog : IParserCatalog -{ - private readonly Dictionary _byId; - - /// Builds a catalog from every parser the container resolved. - /// No parsers were registered, or two share an id. - public ParserCatalog(IEnumerable parsers) - { - ArgumentNullException.ThrowIfNull(parsers); - - Parsers = parsers.OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray(); - - if (Parsers.Count == 0) - { - throw new ArgumentException("At least one parser must be registered.", nameof(parsers)); - } - - _byId = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var parser in Parsers) - { - if (!_byId.TryAdd(parser.Id, parser)) - { - throw new ArgumentException($"Duplicate parser id '{parser.Id}'.", nameof(parsers)); - } - } - } - - /// - public IReadOnlyList Parsers { get; } - - /// - public ITextParser DefaultParser => Parsers[0]; - - /// - public ITextParser? Find(string? id) => id is not null && _byId.TryGetValue(id, out var parser) ? parser : null; -} diff --git a/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs b/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs deleted file mode 100644 index 0e8b561..0000000 --- a/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Runtime.CompilerServices; -using AvParser.Core.Collecting; - -namespace AvParser.Core.Parsing.Samples; - -/// -/// Sample parser: header row plus delimited data rows. Auto-detects the delimiter from the header. -/// -/// -/// Intentionally simple — no quoting, no escapes. It exists to exercise the -/// contract end to end, not to replace a CSV library. -/// -public sealed class DelimitedTextParser : ITextParser -{ - private static readonly char[] Candidates = [',', ';', '\t', '|']; - - /// - public string Id => "delimited"; - - /// - public string DisplayName => "Delimited text"; - - /// - public string Description => - "First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |)."; - - /// - public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Candidates) >= 0; - - /// - public async IAsyncEnumerable> ParseAsync( - string input, - IProgress? progress, - [EnumeratorCancellation] CancellationToken cancellationToken - ) - { - ArgumentNullException.ThrowIfNull(input); - - var lines = TextLines.Split(input); - var total = lines.Length; - string[]? header = null; - char separator = ','; - var processed = 0; - - for (var i = 0; i < lines.Length; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var line = lines[i]; - var lineNumber = i + 1; - processed++; - - if (TextLines.IsSkippable(line)) - { - ReportEvery(progress, processed, total); - continue; - } - - if (header is null) - { - separator = DetectSeparator(line); - header = SplitTrimmed(line, separator); - ReportEvery(progress, processed, total); - continue; - } - - var values = SplitTrimmed(line, separator); - - if (values.Length != header.Length) - { - yield return ParseOutcome.Failure( - lineNumber, - "FieldCount", - $"Expected {header.Length} field(s) but found {values.Length}.", - header.Length, - values.Length - ); - } - else - { - var fields = new ParsedField[values.Length]; - for (var f = 0; f < values.Length; f++) - { - fields[f] = new ParsedField(header[f], values[f]); - } - - yield return ParseOutcome.Success(new ParsedRecord(lineNumber, fields)); - } - - ReportEvery(progress, processed, total); - - if (processed % TextLines.YieldInterval == 0) - { - await Task.Yield(); - } - } - - if (header is null) - { - yield return ParseOutcome.Failure(1, "NoHeader", "Input contains no header row."); - } - - progress?.Report(new ParseProgress(total, total)); - } - - private static void ReportEvery(IProgress? progress, int processed, int total) - { - if (progress is not null && processed % TextLines.ProgressInterval == 0) - { - progress.Report(new ParseProgress(processed, total)); - } - } - - private static char DetectSeparator(string headerLine) - { - var best = Candidates[0]; - var bestCount = 0; - - foreach (var candidate in Candidates) - { - var count = headerLine.Count(c => c == candidate); - if (count > bestCount) - { - bestCount = count; - best = candidate; - } - } - - return best; - } - - private static string[] SplitTrimmed(string line, char separator) - { - var parts = line.Split(separator); - for (var i = 0; i < parts.Length; i++) - { - parts[i] = parts[i].Trim(); - } - - return parts; - } -} diff --git a/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs b/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs deleted file mode 100644 index ae05e8d..0000000 --- a/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Runtime.CompilerServices; -using AvParser.Core.Collecting; - -namespace AvParser.Core.Parsing.Samples; - -/// -/// Sample parser: key=value / key: value lines, ini/env style. -/// -/// -/// A second sample with a different input shape, so the abstraction is proven against more -/// than one implementation before the real domain arrives. -/// -public sealed class KeyValueTextParser : ITextParser -{ - private static readonly char[] Separators = ['=', ':']; - - /// - public string Id => "key-value"; - - /// - public string DisplayName => "Key / value pairs"; - - /// - public string Description => "One pair per line, separated by '=' or ':'. Lines starting with '#' are comments."; - - /// - public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Separators) >= 0; - - /// - public async IAsyncEnumerable> ParseAsync( - string input, - IProgress? progress, - [EnumeratorCancellation] CancellationToken cancellationToken - ) - { - ArgumentNullException.ThrowIfNull(input); - - var lines = TextLines.Split(input); - var total = lines.Length; - - for (var i = 0; i < lines.Length; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var line = lines[i]; - var lineNumber = i + 1; - var processed = i + 1; - - if (!TextLines.IsSkippable(line)) - { - var separatorIndex = line.IndexOfAny(Separators); - - if (separatorIndex <= 0) - { - yield return ParseOutcome.Failure( - lineNumber, - "NoSeparator", - "No '=' or ':' separator found." - ); - } - else - { - var key = line[..separatorIndex].Trim(); - var value = line[(separatorIndex + 1)..].Trim(); - - yield return key.Length == 0 - ? ParseOutcome.Failure(lineNumber, "EmptyKey", "Key is empty.") - : ParseOutcome.Success( - new ParsedRecord(lineNumber, [new ParsedField("Key", key), new ParsedField("Value", value)]) - ); - } - } - - if (progress is not null && processed % TextLines.ProgressInterval == 0) - { - progress.Report(new ParseProgress(processed, total)); - } - - if (processed % TextLines.YieldInterval == 0) - { - await Task.Yield(); - } - } - - progress?.Report(new ParseProgress(total, total)); - } -} diff --git a/src/AvParser.Core/Settings/AppSettings.cs b/src/AvParser.Core/Settings/AppSettings.cs index 920db93..65de508 100644 --- a/src/AvParser.Core/Settings/AppSettings.cs +++ b/src/AvParser.Core/Settings/AppSettings.cs @@ -34,7 +34,6 @@ public enum AppTheme /// /// Chosen theme variant. /// Chosen UI language. -/// Id of the parser selected last time; resolved leniently on load. /// Last main-window width in device-independent pixels. /// Last main-window height in device-independent pixels. /// Whether the main window was maximised on exit. @@ -65,7 +64,6 @@ public enum AppTheme public sealed record AppSettings( AppTheme Theme = AppTheme.System, AppLanguage Language = AppLanguage.System, - string? LastParserId = null, double WindowWidth = 1280, double WindowHeight = 800, bool WindowMaximized = false, diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs index 3dfe9f8..aed38fb 100644 --- a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using AvParser.Core.Collecting; -using AvParser.Core.Parsing; using AvParser.Core.Proxies; using AvParser.Core.Settings; using AvParser.Infrastructure.Proxies; @@ -32,13 +31,6 @@ public static class UiServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(static sp => new ParseViewModel( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp, - sp.GetRequiredService>() - )); services.AddSingleton(static sp => new SettingsViewModel( sp.GetRequiredService(), sp.GetRequiredService(), @@ -66,7 +58,6 @@ public static class UiServiceCollectionExtensions // Order here is the order of the navigation rail; the first entry is the landing page. services.AddSingleton(static sp => sp.GetRequiredService()); - services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); diff --git a/src/AvParser.UI/Localization/Strings.resx b/src/AvParser.UI/Localization/Strings.resx index f06f18a..5711548 100644 --- a/src/AvParser.UI/Localization/Strings.resx +++ b/src/AvParser.UI/Localization/Strings.resx @@ -70,9 +70,6 @@ Dashboard - - Parse - Proxies @@ -85,90 +82,12 @@ A parser shell with an adaptive layout. Drag the window narrower to watch the navigation collapse to an icon rail and then to an overlay drawer. - - Registered parsers - Get started - - Open the parser - DATA DIRECTORY - - PARSER - - - ACTIONS - - - Parse - - - Cancel - - - Sample - - - Fill the input with a small example - - - 50k rows - - - Generate 50 000 rows, so progress and cancellation are observable - - - Clear input and results - - - INPUT - - - Paste text here, or press Sample - - - RECORDS - - - ERRORS - - - {0} record - - - {0} records - - - {0} records - - - {0} error - - - {0} errors - - - {0} errors - - - Parsed {0} in {1} ms. - - - Cancelled after {0} in {1} ms. - - - {0}, {1}. - - - Showing the first {0} only. - - - Parse failed: {0} - POOL @@ -463,44 +382,14 @@ Unchecked - - Delimited text - - - First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |). - - - Key / value pairs - - - One pair per line, separated by '=' or ':'. Lines starting with '#' are comments. - - - Expected {0} field(s) but found {1}. - - - Input contains no header row. - - - No '=' or ':' separator found. - - - Key is empty. - {0} in the pool, {1} live. - - This parser needs a working proxy, and none is available. Check the proxy list, or allow direct connections in settings. - - - Open proxies - - Allow network parsers to run without a proxy + Allow network sources to run without a proxy - When off, a parser that fetches anything refuses to start until at least one proxy answers. Parsers that only read pasted text are never blocked. + When off, a source that fetches anything refuses to start until at least one proxy answers. Sources that only read pasted text are never blocked. LIVE PROXIES TO FIND AT STARTUP @@ -616,64 +505,64 @@ Reads the listing endpoint of a service you run. - + '{0}' is not an http or https address. - + No listing endpoint was given. - + Could not read the listing: {0} - + The listing could not be understood: {0} - + The source stopped: {0} - + No live proxy, and direct connections are switched off. - + Redirected somewhere that is not an http address. - + The redirects loop back on themselves. - + Too many redirects. - + The response was not an image or a video. - + Larger than the size limit. - + Smaller than the size floor; probably a tracking pixel. - + The transfer ended early. - + Timed out. - + The site asked to slow down. - + A known dead-link placeholder. - + That format is switched off. - + The site answered {0}. - + The request failed: {0} - + Could not be collected. @@ -739,4 +628,10 @@ {0} files + + Registered sources + + + Open the collector + diff --git a/src/AvParser.UI/Localization/Strings.ru.resx b/src/AvParser.UI/Localization/Strings.ru.resx index 2b87e3b..4bde3a8 100644 --- a/src/AvParser.UI/Localization/Strings.ru.resx +++ b/src/AvParser.UI/Localization/Strings.ru.resx @@ -70,9 +70,6 @@ Обзор - - Разбор - Прокси @@ -83,92 +80,14 @@ О программе - Оболочка парсера с адаптивной раскладкой. Сузьте окно — навигация свернётся сначала в рельс из иконок, а затем в выезжающую панель. - - - Доступные парсеры + Собирает изображения и гифки в локальное хранилище. Сузьте окно — навигация свернётся сначала в рельс из иконок, а затем в выезжающую панель. С чего начать - - Открыть парсер - КАТАЛОГ ДАННЫХ - - ПАРСЕР - - - ДЕЙСТВИЯ - - - Разобрать - - - Отменить - - - Пример - - - Подставить небольшой пример - - - 50k строк - - - Сгенерировать 50 000 строк, чтобы были видны прогресс и отмена - - - Очистить ввод и результаты - - - ВХОД - - - Вставьте текст или нажмите «Пример» - - - ЗАПИСИ - - - ОШИБКИ - - - {0} запись - - - {0} записи - - - {0} записей - - - {0} ошибка - - - {0} ошибки - - - {0} ошибок - - - Разобрано: {0} за {1} мс. - - - Отменено: {0} за {1} мс. - - - {0}, {1}. - - - Показаны только первые {0}. - - - Разбор не удался: {0} - ПУЛ @@ -463,44 +382,14 @@ Не проверены - - Текст с разделителями - - - Первая непустая строка — заголовок. Строки режутся по разделителю, преобладающему в нём (, ; таб |). - - - Пары ключ / значение - - - По паре в строке, разделитель «=» или «:». Строки с «#» — комментарии. - - - Ожидалось полей: {0}, найдено: {1}. - - - Во входных данных нет строки заголовка. - - - Не найден разделитель «=» или «:». - - - Пустой ключ. - В пуле {0}, живых {1}. - - Этому парсеру нужна рабочая прокси, а живых нет. Проверьте список прокси или разрешите прямое подключение в настройках. - - - Открыть прокси - - Разрешить сетевым парсерам работать без прокси + Разрешить сетевым источникам работать без прокси - Когда выключено, парсер, который куда-то ходит, не запустится, пока не ответит хотя бы одна прокси. Парсеры, читающие вставленный текст, не блокируются никогда. + Когда выключено, источник, который куда-то ходит, не запустится, пока не ответит хотя бы одна прокси. Источники, читающие вставленный текст, не блокируются никогда. СКОЛЬКО ЖИВЫХ ИСКАТЬ ПРИ СТАРТЕ @@ -616,64 +505,64 @@ Читает листинг сервиса, который вы держите сами. - + «{0}» — не http- и не https-адрес. - + Не указан адрес листинга. - + Не удалось прочитать листинг: {0} - + Листинг не удалось разобрать: {0} - + Источник остановился: {0} - + Живых прокси нет, а прямое подключение выключено. - + Перенаправление на адрес, который не является http. - + Перенаправления зациклены. - + Слишком много перенаправлений. - + В ответе не изображение и не видео. - + Больше предельного размера. - + Меньше нижнего порога; вероятно, трекинг-пиксель. - + Передача оборвалась. - + Истекло время ожидания. - + Сайт попросил снизить темп. - + Известная заглушка мёртвой ссылки. - + Этот формат отключён. - + Сайт ответил {0}. - + Запрос не удался: {0} - + Не удалось собрать. @@ -739,4 +628,10 @@ {0} файлов + + Зарегистрированные источники + + + Открыть сбор + diff --git a/src/AvParser.UI/ViewModels/ParseErrorViewModel.cs b/src/AvParser.UI/ViewModels/CollectErrorViewModel.cs similarity index 60% rename from src/AvParser.UI/ViewModels/ParseErrorViewModel.cs rename to src/AvParser.UI/ViewModels/CollectErrorViewModel.cs index 7e0c60c..f2ebf84 100644 --- a/src/AvParser.UI/ViewModels/ParseErrorViewModel.cs +++ b/src/AvParser.UI/ViewModels/CollectErrorViewModel.cs @@ -4,19 +4,19 @@ using ReactiveUI; namespace AvParser.UI.ViewModels; -/// One row of the parse error list, with its message translated. +/// One row of the collect error list, with its message translated. /// -/// Parsers are part of the domain and produce English text plus a code. This resolves -/// Parse.Error.{Code} and falls back to the parser's own wording, so a parser that has not +/// Sources and the fetcher are part of the domain and produce English text plus a code. This resolves +/// Collect.Error.{Code} and falls back to the domain's own wording, so a code that has not /// been translated yet still says something useful instead of showing a missing-key marker. /// -public sealed class ParseErrorViewModel(ParseError error) : ReactiveObject +public sealed class CollectErrorViewModel(ParseError error) : ReactiveObject { /// The underlying error. public ParseError Error { get; } = error ?? throw new ArgumentNullException(nameof(error)); - /// 1-based position of the offending record. - public int LineNumber => Error.LineNumber; + /// 1-based position of the offending item: a pasted line, or a place in a listing. + public int Index => Error.Index; /// Translated message. public string Text @@ -28,7 +28,7 @@ public sealed class ParseErrorViewModel(ParseError error) : ReactiveObject return Error.Message; } - var template = Localizer.Instance.GetOrDefault($"Parse.Error.{code}", Error.Message); + var template = Localizer.Instance.GetOrDefault($"Collect.Error.{code}", Error.Message); return Error.Arguments.Count == 0 ? template : string.Format(Localizer.Instance.Culture, template, [.. Error.Arguments]); diff --git a/src/AvParser.UI/ViewModels/CollectViewModel.cs b/src/AvParser.UI/ViewModels/CollectViewModel.cs index fcd741c..40a2fee 100644 --- a/src/AvParser.UI/ViewModels/CollectViewModel.cs +++ b/src/AvParser.UI/ViewModels/CollectViewModel.cs @@ -206,7 +206,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable public ObservableCollection Items { get; } = []; /// Per-item failures. A failure never aborts the run. - public ObservableCollection Errors { get; } = []; + public ObservableCollection Errors { get; } = []; /// Whether a collection is currently running. public bool IsBusy => _isBusy.Value; @@ -485,7 +485,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable foreach (var error in errorBatch) { - Errors.Add(new ParseErrorViewModel(error)); + Errors.Add(new CollectErrorViewModel(error)); } }); } diff --git a/src/AvParser.UI/ViewModels/DashboardViewModel.cs b/src/AvParser.UI/ViewModels/DashboardViewModel.cs index 5e25bf8..85693d7 100644 --- a/src/AvParser.UI/ViewModels/DashboardViewModel.cs +++ b/src/AvParser.UI/ViewModels/DashboardViewModel.cs @@ -1,4 +1,4 @@ -using AvParser.Core.Parsing; +using AvParser.Core.Collecting; using AvParser.Infrastructure.Storage; using AvParser.UI.Navigation; using Microsoft.Extensions.DependencyInjection; @@ -13,24 +13,25 @@ public sealed class DashboardViewModel : PageViewModel private readonly IServiceProvider _services; /// Creates the dashboard. - /// Registered parsers, shown as cards. + /// Registered media sources, shown as cards. /// Where the app writes settings and logs. /// /// Used to resolve at click time rather than at construction /// time. Injecting it directly would be a cycle: the navigation service is built from every /// page, so a page cannot also depend on it up front. /// - public DashboardViewModel(IParserCatalog catalog, IAppPaths paths, IServiceProvider services) + public DashboardViewModel(IMediaSourceCatalog catalog, IAppPaths paths, IServiceProvider services) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(paths); _services = services ?? throw new ArgumentNullException(nameof(services)); - Parsers = [.. catalog.Parsers.Select(parser => new ParserViewModel(parser))]; + Sources = [.. catalog.Sources.Select(source => new MediaSourceViewModel(source))]; DataDirectory = paths.DataDirectory; + MediaDirectory = paths.MediaDirectory; - GoToParseCommand = ReactiveCommand.Create(() => Navigate()); + GoToCollectCommand = ReactiveCommand.Create(() => Navigate()); GoToSettingsCommand = ReactiveCommand.Create(() => Navigate()); } @@ -40,14 +41,17 @@ public sealed class DashboardViewModel : PageViewModel /// public override string IconKey => "IconHome"; - /// Registered parsers, shown as cards. - public IReadOnlyList Parsers { get; } + /// Registered media sources, shown as cards. + public IReadOnlyList Sources { get; } /// Where settings and logs are written. public string DataDirectory { get; } - /// Jumps to the Parse page. - public ReactiveCommand GoToParseCommand { get; } + /// Where collected media is written. + public string MediaDirectory { get; } + + /// Jumps to the Collect page. + public ReactiveCommand GoToCollectCommand { get; } /// Jumps to the Settings page. public ReactiveCommand GoToSettingsCommand { get; } diff --git a/src/AvParser.UI/ViewModels/ParseViewModel.cs b/src/AvParser.UI/ViewModels/ParseViewModel.cs deleted file mode 100644 index a9b5027..0000000 --- a/src/AvParser.UI/ViewModels/ParseViewModel.cs +++ /dev/null @@ -1,438 +0,0 @@ -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Globalization; -using System.Text; -using AvParser.Core.Parsing; -using AvParser.Core.Proxies; -using AvParser.Core.Settings; -using AvParser.UI.Localization; -using AvParser.UI.Navigation; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ReactiveUI; -using ReactiveUI.Primitives; -using ReactiveUI.Primitives.Concurrency; -using ReactiveUI.Primitives.Signals; -using ReactiveUI.SourceGenerators; - -namespace AvParser.UI.ViewModels; - -/// Runs a parser over pasted text and streams the results into the UI. -/// -/// This page exists to exercise the whole contract — -/// streaming, progress and cancellation — rather than to be a finished feature. -/// -public partial class ParseViewModel : PageViewModel, IDisposable -{ - /// Records buffered before being pushed to the UI collection in one go. - private const int BatchSize = 512; - - /// - /// Upper bound on rows shown. Beyond this the parse still completes and the count stays - /// accurate, but the list stops growing — truncation is reported, never silent. - /// - private const int MaxDisplayedRecords = 20_000; - - private readonly IParserCatalog _catalog; - private readonly ISettingsService _settings; - private readonly IProxyPool _proxyPool; - private readonly IServiceProvider _services; - private readonly ILogger _logger; - private readonly ISequencer _mainThread; - private readonly ObservableAsPropertyHelper _isBusy; - - private readonly Signal _proxyChanged = new(); - - private CancellationTokenSource? _cancellation; - - /// Text to parse. - [Reactive] - public partial string InputText { get; set; } - - /// Parser applied by . - [Reactive] - public partial ParserViewModel SelectedParser { get; set; } - - /// Completion of the running parse, 0.0 to 1.0. - [Reactive] - public partial double Progress { get; set; } - - /// Outcome summary shown under the toolbar; when idle. - [Reactive] - public partial string? StatusMessage { get; set; } - - /// - /// Whether the selected parser needs the network but has no working proxy to use. - /// - /// - /// Only network parsers are gated. A parser that works on text the user pasted has nothing to - /// route, and blocking it would make the app unusable whenever the public lists are down. - /// - [Reactive] - public partial bool IsBlockedWithoutProxy { get; set; } - - /// Creates the page. - /// Available parsers. - /// Used to remember the selected parser. - /// Consulted for the live count that gates network parsers. - /// Resolves the navigation service lazily, to keep pages acyclic. - /// Diagnostics. - /// - /// Scheduler used to marshal collection and progress updates back to the UI thread. Tests - /// pass to make everything synchronous. - /// - public ParseViewModel( - IParserCatalog catalog, - ISettingsService settings, - IProxyPool proxyPool, - IServiceProvider services, - ILogger logger, - ISequencer? mainThread = null - ) - { - _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); - _settings = settings ?? throw new ArgumentNullException(nameof(settings)); - _proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool)); - _services = services ?? throw new ArgumentNullException(nameof(services)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _mainThread = mainThread ?? RxSchedulers.MainThreadScheduler; - - InputText = string.Empty; - Parsers = [.. catalog.Parsers.Select(parser => new ParserViewModel(parser))]; - SelectedParser = Parsers.First(parser => parser.Id == catalog.FindOrDefault(settings.Current.LastParserId).Id); - - // The pool changes on every lease outcome and on every probe, so coalesce before - // re-evaluating whether the parser is allowed to run. - _proxyPool.Changed += OnProxyPoolChanged; - _proxyChanged - .Throttle(TimeSpan.FromMilliseconds(250), _mainThread) - .ObserveOn(_mainThread) - .Subscribe(_ => RefreshProxyGate()); - - RefreshProxyGate(); - - var canParse = this.WhenAnyValue( - x => x.InputText, - x => x.IsBlockedWithoutProxy, - static (text, blocked) => (text, blocked) - ) - .Select(static state => !string.IsNullOrWhiteSpace(state.text) && !state.blocked) - .DistinctUntilChanged(); - - ParseCommand = ReactiveCommand.CreateFromTask(RunParseAsync, canParse, _mainThread); - - GoToProxiesCommand = ReactiveCommand.Create( - () => _services.GetRequiredService().NavigateTo(), - outputScheduler: _mainThread - ); - _isBusy = ParseCommand.IsExecuting.ToProperty(this, nameof(IsBusy), false, _mainThread); - - CancelCommand = ReactiveCommand.Create(() => _cancellation?.Cancel(), ParseCommand.IsExecuting, _mainThread); - - ClearCommand = ReactiveCommand.Create( - () => - { - InputText = string.Empty; - ClearResults(); - StatusMessage = null; - Progress = 0d; - }, - ParseCommand.IsExecuting.Select(static running => !running), - _mainThread - ); - - LoadSampleCommand = ReactiveCommand.Create( - () => InputText = SampleFor(SelectedParser.Id), - ParseCommand.IsExecuting.Select(static running => !running), - _mainThread - ); - - GenerateLargeSampleCommand = ReactiveCommand.Create( - () => InputText = LargeSampleFor(SelectedParser.Id), - ParseCommand.IsExecuting.Select(static running => !running), - _mainThread - ); - - // Remember the parser choice; the debounced settings service coalesces the writes. - // Switching parser can also change whether the gate applies, since only network parsers - // are gated. - this.WhenAnyValue(x => x.SelectedParser) - .Where(static parser => parser is not null) - .Subscribe(parser => - { - _settings.Update(current => current with { LastParserId = parser.Id }); - RefreshProxyGate(); - }); - - _settings.Changes.Subscribe(_ => RefreshProxyGate()); - - // Errors surfacing from any command must not tear the process down. - ParseCommand.ThrownExceptions.Subscribe(OnCommandFailed); - } - - /// - public override string TitleKey => "Page.Parse"; - - /// - public override string IconKey => "IconDocument"; - - /// Every registered parser, for the picker. - public IReadOnlyList Parsers { get; } - - /// Successfully parsed records, capped at . - public ObservableCollection Records { get; } = []; - - /// Per-line failures. A failure never aborts the parse. - public ObservableCollection Errors { get; } = []; - - /// Whether a parse is currently running. - public bool IsBusy => _isBusy.Value; - - /// Runs over . - public ReactiveCommand ParseCommand { get; } - - /// Cancels the running parse. - public ReactiveCommand CancelCommand { get; } - - /// Clears the input and all results. - public ReactiveCommand ClearCommand { get; } - - /// Fills the input with a small example for the selected parser. - public ReactiveCommand LoadSampleCommand { get; } - - /// Fills the input with 50 000 rows, so progress and cancellation are observable. - public ReactiveCommand GenerateLargeSampleCommand { get; } - - /// Takes the user to the page where the proxy problem can be fixed. - public ReactiveCommand GoToProxiesCommand { get; } - - /// Explains why parsing is blocked. - public string ProxyRequiredMessage => Localizer.Instance["Parse.ProxyRequired"]; - - /// Re-evaluates the proxy gate. Exposed so tests can drive it without waiting. - public void RefreshProxyGate() - { - var settings = _settings.Current; - - IsBlockedWithoutProxy = - SelectedParser.Parser.RequiresNetwork && !settings.AllowDirectConnection && _proxyPool.LiveCount == 0; - } - - private void OnProxyPoolChanged(object? sender, EventArgs e) => _proxyChanged.OnNext(RxVoid.Default); - - private async Task RunParseAsync(CancellationToken commandToken) - { - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(commandToken); - _cancellation = cancellation; - - var parser = SelectedParser.Parser; - var input = InputText; - var token = cancellation.Token; - - ClearResults(); - Progress = 0d; - StatusMessage = null; - - var recordBuffer = new List(BatchSize); - var errorBuffer = new List(16); - var progress = new Progress(value => OnUi(() => Progress = value.Fraction)); - - var stopwatch = Stopwatch.StartNew(); - var succeeded = 0; - var failed = 0; - var truncated = false; - var cancelled = false; - - try - { - await foreach (var outcome in parser.ParseAsync(input, progress, token).ConfigureAwait(false)) - { - if (outcome.IsSuccess) - { - succeeded++; - if (succeeded <= MaxDisplayedRecords) - { - recordBuffer.Add(outcome.Value!); - } - else - { - truncated = true; - } - } - else - { - failed++; - errorBuffer.Add(outcome.Error); - } - - if (recordBuffer.Count >= BatchSize) - { - FlushBuffers(recordBuffer, errorBuffer); - } - } - } - catch (OperationCanceledException) - { - cancelled = true; - } - finally - { - _cancellation = null; - FlushBuffers(recordBuffer, errorBuffer); - stopwatch.Stop(); - } - - var summary = BuildSummary(succeeded, failed, stopwatch.Elapsed, truncated, cancelled); - OnUi(() => - { - StatusMessage = summary; - Progress = cancelled ? Progress : 1d; - }); - - _logger.LogInformation( - "Parsed with {Parser}: {Succeeded} record(s), {Failed} error(s) in {Elapsed}", - parser.Id, - succeeded, - failed, - stopwatch.Elapsed - ); - } - - /// - /// Builds the outcome line out of translated fragments. - /// - /// - /// Assembled from plural-aware pieces rather than one format string per case: Russian needs - /// three forms for a counted noun, so "{0} records" with an English plural glued on cannot be - /// translated correctly. - /// - internal static string BuildSummary(int succeeded, int failed, TimeSpan elapsed, bool truncated, bool cancelled) - { - var loc = Localizer.Instance; - var records = loc.Plural("Parse.Count.Records", succeeded); - var milliseconds = elapsed.TotalMilliseconds.ToString("N0", CultureInfo.CurrentCulture); - - var text = loc.Format(cancelled ? "Parse.Status.Cancelled" : "Parse.Status.Done", records, milliseconds); - - if (failed > 0) - { - text = loc.Format("Parse.Status.WithErrors", text.TrimEnd('.'), loc.Plural("Parse.Count.Errors", failed)); - } - - if (truncated) - { - text += " " + loc.Format("Parse.Status.Truncated", loc.Plural("Parse.Count.Records", MaxDisplayedRecords)); - } - - return text; - } - - private void FlushBuffers(List records, List errors) - { - if (records.Count == 0 && errors.Count == 0) - { - return; - } - - // Copy before clearing: the scheduled callback may run after the loop has refilled these. - var recordBatch = records.ToArray(); - var errorBatch = errors.ToArray(); - records.Clear(); - errors.Clear(); - - OnUi(() => - { - foreach (var record in recordBatch) - { - Records.Add(record); - } - - foreach (var error in errorBatch) - { - Errors.Add(new ParseErrorViewModel(error)); - } - }); - } - - /// - /// The pool is a singleton and would otherwise keep this page alive for the process. - public void Dispose() - { - _proxyPool.Changed -= OnProxyPoolChanged; - _proxyChanged.Dispose(); - GC.SuppressFinalize(this); - } - - /// - protected override void OnLanguageChanged() - { - base.OnLanguageChanged(); - - this.RaisePropertyChanged(nameof(ProxyRequiredMessage)); - - // The summary and the listed errors were both rendered in the previous language. - StatusMessage = null; - - foreach (var error in Errors) - { - error.Refresh(); - } - } - - private void ClearResults() - { - Records.Clear(); - Errors.Clear(); - } - - private void OnCommandFailed(Exception exception) - { - _logger.LogError(exception, "Parse failed"); - OnUi(() => StatusMessage = Localizer.Instance.Format("Parse.Status.Failed", exception.Message)); - } - - /// Marshals a mutation onto the UI thread; the parse loop runs on the thread pool. - private void OnUi(Action action) => _mainThread.Schedule(action); - - private static string SampleFor(string parserId) => - parserId switch - { - "key-value" => """ - # Sample configuration - host = localhost - port: 8080 - enabled = true - name = av-parser - """, - _ => """ - id,name,role - 1,Ada Lovelace,Analyst - 2,Grace Hopper,Compiler - 3,Alan Turing,Cryptanalyst - """, - }; - - private static string LargeSampleFor(string parserId) - { - const int rows = 50_000; - var text = new StringBuilder(rows * 24); - - if (parserId == "key-value") - { - for (var i = 0; i < rows; i++) - { - text.Append("key").Append(i).Append(" = value").Append(i).Append('\n'); - } - - return text.ToString(); - } - - text.Append("id,name,score\n"); - for (var i = 0; i < rows; i++) - { - text.Append(i).Append(",item-").Append(i).Append(',').Append(i % 100).Append('\n'); - } - - return text.ToString(); - } -} diff --git a/src/AvParser.UI/ViewModels/ParserViewModel.cs b/src/AvParser.UI/ViewModels/ParserViewModel.cs deleted file mode 100644 index 26b20c8..0000000 --- a/src/AvParser.UI/ViewModels/ParserViewModel.cs +++ /dev/null @@ -1,43 +0,0 @@ -using AvParser.Core.Parsing; -using AvParser.UI.Localization; -using ReactiveUI; - -namespace AvParser.UI.ViewModels; - -/// A parser paired with its translated name and description. -/// -/// The domain deliberately knows nothing about languages, so carries -/// English text. This looks the id up as Parser.{id}.Name and falls back to what the parser -/// itself says, which keeps the "add a parser = one registration line" promise intact: a new -/// parser works untranslated instead of rendering a missing-key marker. -/// -public sealed class ParserViewModel : ReactiveObject -{ - /// Wraps a parser. - public ParserViewModel(ITextParser parser) - { - Parser = parser ?? throw new ArgumentNullException(nameof(parser)); - Localizer.Instance.LanguageChanged += OnLanguageChanged; - } - - /// The parser itself. - public ITextParser Parser { get; } - - /// Stable identifier. - public string Id => Parser.Id; - - /// Translated name, or the parser's own when untranslated. - public string Name => Localizer.Instance.GetOrDefault($"Parser.{Id}.Name", Parser.DisplayName); - - /// Translated description, or the parser's own when untranslated. - public string Description => Localizer.Instance.GetOrDefault($"Parser.{Id}.Description", Parser.Description); - - /// - public override string ToString() => Name; - - private void OnLanguageChanged(object? sender, EventArgs e) - { - this.RaisePropertyChanged(nameof(Name)); - this.RaisePropertyChanged(nameof(Description)); - } -} diff --git a/src/AvParser.UI/Views/CollectView.axaml b/src/AvParser.UI/Views/CollectView.axaml index 6f6bd7f..476d601 100644 --- a/src/AvParser.UI/Views/CollectView.axaml +++ b/src/AvParser.UI/Views/CollectView.axaml @@ -284,10 +284,10 @@ - + - + diff --git a/src/AvParser.UI/Views/DashboardView.axaml b/src/AvParser.UI/Views/DashboardView.axaml index 7f273df..bd650b6 100644 --- a/src/AvParser.UI/Views/DashboardView.axaml +++ b/src/AvParser.UI/Views/DashboardView.axaml @@ -14,15 +14,15 @@ - - + + - + @@ -40,10 +40,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/AvParser.UI/Views/ParseView.axaml.cs b/src/AvParser.UI/Views/ParseView.axaml.cs deleted file mode 100644 index bb84f8d..0000000 --- a/src/AvParser.UI/Views/ParseView.axaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Markup.Xaml; - -namespace AvParser.UI.Views; - -/// Input, toolbar and streamed parse results. -public partial class ParseView : UserControl -{ - /// Creates the view. - public ParseView() => InitializeComponent(); - - private void InitializeComponent() => AvaloniaXamlLoader.Load(this); -} diff --git a/tests/AvParser.Core.Tests/DelimitedTextParserTests.cs b/tests/AvParser.Core.Tests/DelimitedTextParserTests.cs deleted file mode 100644 index 6ce9570..0000000 --- a/tests/AvParser.Core.Tests/DelimitedTextParserTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using AvParser.Core.Parsing; -using AvParser.Core.Parsing.Samples; - -namespace AvParser.Core.Tests; - -public class DelimitedTextParserTests -{ - private readonly DelimitedTextParser _parser = new(); - - [Fact] - public async Task Parses_header_and_rows() - { - var (records, errors) = await _parser.CollectAsync("id,name\n1,Ada\n2,Grace"); - - errors.ShouldBeEmpty(); - records.Count.ShouldBe(2); - records[0].Field("id").ShouldBe("1"); - records[0].Field("name").ShouldBe("Ada"); - records[1].LineNumber.ShouldBe(3); - } - - [Theory] - [InlineData("a;b\n1;2")] - [InlineData("a\tb\n1\t2")] - [InlineData("a|b\n1|2")] - public async Task Detects_the_delimiter_from_the_header(string input) - { - var (records, errors) = await _parser.CollectAsync(input); - - errors.ShouldBeEmpty(); - records.ShouldHaveSingleItem().Fields.Count.ShouldBe(2); - } - - [Fact] - public async Task Reports_a_field_count_mismatch_without_aborting() - { - var (records, errors) = await _parser.CollectAsync("id,name\n1\n2,Grace"); - - // The bad line becomes an error; the good line after it still parses. - errors.ShouldHaveSingleItem().LineNumber.ShouldBe(2); - records.ShouldHaveSingleItem().Field("name").ShouldBe("Grace"); - } - - [Fact] - public async Task Skips_blank_lines_and_comments() - { - var (records, errors) = await _parser.CollectAsync("# a comment\nid,name\n\n1,Ada\n"); - - errors.ShouldBeEmpty(); - records.ShouldHaveSingleItem().Field("name").ShouldBe("Ada"); - } - - [Fact] - public async Task Trims_surrounding_whitespace() - { - var (records, _) = await _parser.CollectAsync("id , name\n 1 , Ada "); - - records.ShouldHaveSingleItem().Field("name").ShouldBe("Ada"); - } - - [Fact] - public async Task Reports_an_error_when_there_is_no_header() - { - var (records, errors) = await _parser.CollectAsync("\n\n"); - - records.ShouldBeEmpty(); - errors.ShouldHaveSingleItem().Message.ShouldContain("header"); - } - - [Fact] - public async Task Reports_progress_reaching_completion() - { - var reports = new List(); - - // Not Progress: it posts to the captured synchronization context, so the reports would - // arrive after the assertions. A direct IProgress keeps the test deterministic. - await foreach ( - var _ in _parser.ParseAsync( - ParserTestExtensions.DelimitedDocument(900), - new SynchronousProgress(reports.Add), - TestContext.Current.CancellationToken - ) - ) { } - - reports.ShouldNotBeEmpty(); - reports[^1].Fraction.ShouldBe(1d); - } - - [Fact] - public async Task Honours_cancellation() - { - using var cancellation = new CancellationTokenSource(); - - var act = async () => - { - await foreach ( - var _ in _parser.ParseAsync(ParserTestExtensions.DelimitedDocument(20_000), null, cancellation.Token) - ) - { - await cancellation.CancelAsync(); - } - }; - - await act.ShouldThrowAsync(); - } - - [Theory] - [InlineData("", false)] - [InlineData(" ", false)] - [InlineData("no delimiters here", false)] - [InlineData("a,b", true)] - public void CanParse_checks_for_a_delimiter(string input, bool expected) => - _parser.CanParse(input).ShouldBe(expected); -} diff --git a/tests/AvParser.Core.Tests/KeyValueTextParserTests.cs b/tests/AvParser.Core.Tests/KeyValueTextParserTests.cs deleted file mode 100644 index 898a7bf..0000000 --- a/tests/AvParser.Core.Tests/KeyValueTextParserTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using AvParser.Core.Parsing.Samples; - -namespace AvParser.Core.Tests; - -public class KeyValueTextParserTests -{ - private readonly KeyValueTextParser _parser = new(); - - [Theory] - [InlineData("host = localhost")] - [InlineData("host: localhost")] - public async Task Accepts_both_separators(string input) - { - var (records, errors) = await _parser.CollectAsync(input); - - errors.ShouldBeEmpty(); - var record = records.ShouldHaveSingleItem(); - record.Field("Key").ShouldBe("host"); - record.Field("Value").ShouldBe("localhost"); - } - - [Fact] - public async Task Splits_on_the_first_separator_only() - { - var (records, _) = await _parser.CollectAsync("url = https://example.com:8080/path"); - - records.ShouldHaveSingleItem().Field("Value").ShouldBe("https://example.com:8080/path"); - } - - [Fact] - public async Task Reports_lines_without_a_separator() - { - var (records, errors) = await _parser.CollectAsync("host = localhost\ngarbage\nport = 80"); - - records.Count.ShouldBe(2); - errors.ShouldHaveSingleItem().LineNumber.ShouldBe(2); - } - - [Fact] - public async Task Reports_an_empty_key() - { - var (_, errors) = await _parser.CollectAsync("= orphan"); - - errors.ShouldHaveSingleItem().Message.ShouldContain("separator"); - } - - [Fact] - public async Task Skips_comments_and_blank_lines() - { - var (records, errors) = await _parser.CollectAsync("# comment\n\nhost = localhost\n"); - - errors.ShouldBeEmpty(); - records.ShouldHaveSingleItem().Field("Key").ShouldBe("host"); - } - - [Fact] - public async Task Allows_an_empty_value() - { - var (records, errors) = await _parser.CollectAsync("host ="); - - errors.ShouldBeEmpty(); - records.ShouldHaveSingleItem().Field("Value").ShouldBe(string.Empty); - } -} diff --git a/tests/AvParser.Core.Tests/ParserCatalogTests.cs b/tests/AvParser.Core.Tests/ParserCatalogTests.cs deleted file mode 100644 index 06a9da3..0000000 --- a/tests/AvParser.Core.Tests/ParserCatalogTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using AvParser.Core.Parsing; -using AvParser.Core.Parsing.Samples; - -namespace AvParser.Core.Tests; - -public class ParserCatalogTests -{ - private static IParserCatalog Catalog() => new ParserCatalog([new KeyValueTextParser(), new DelimitedTextParser()]); - - [Fact] - public void Orders_parsers_by_display_name_regardless_of_registration_order() => - Catalog().Parsers.Select(p => p.Id).ShouldBe(["delimited", "key-value"]); - - [Fact] - public void Finds_a_parser_by_id_ignoring_case() => Catalog().Find("KEY-VALUE")!.Id.ShouldBe("key-value"); - - [Fact] - public void Returns_null_for_an_unknown_id() => Catalog().Find("nope").ShouldBeNull(); - - [Fact] - public void Falls_back_to_the_default_for_an_unknown_id() - { - var catalog = Catalog(); - - catalog.FindOrDefault("nope").ShouldBeSameAs(catalog.DefaultParser); - catalog.FindOrDefault(null).ShouldBeSameAs(catalog.DefaultParser); - } - - [Fact] - public void Rejects_an_empty_registration() => Should.Throw(() => new ParserCatalog([])); - - [Fact] - public void Rejects_duplicate_ids() => - Should - .Throw(() => new ParserCatalog([new DelimitedTextParser(), new DelimitedTextParser()])) - .Message.ShouldContain("Duplicate"); -} diff --git a/tests/AvParser.Core.Tests/ParserTestExtensions.cs b/tests/AvParser.Core.Tests/ParserTestExtensions.cs deleted file mode 100644 index 39578d8..0000000 --- a/tests/AvParser.Core.Tests/ParserTestExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using AvParser.Core.Parsing; - -namespace AvParser.Core.Tests; - -/// Collection helpers so the tests read as assertions rather than as loops. -internal static class ParserTestExtensions -{ - /// - /// Drains a parse into memory, using the ambient test cancellation token. - /// - /// - /// Deliberately takes no : every call site would otherwise have - /// to pass TestContext.Current.CancellationToken to satisfy xUnit1051. Cancellation - /// behaviour is covered by driving directly. - /// - internal static async Task<(List Records, List Errors)> CollectAsync( - this ITextParser parser, - string input, - IProgress? progress = null - ) - { - var records = new List(); - var errors = new List(); - - await foreach (var outcome in parser.ParseAsync(input, progress, TestContext.Current.CancellationToken)) - { - if (outcome.IsSuccess) - { - records.Add(outcome.Value!); - } - else - { - errors.Add(outcome.Error); - } - } - - return (records, errors); - } - - /// Reads a field by name, failing the test if it is absent. - internal static string Field(this ParsedRecord record, string name) => - record[name] ?? throw new InvalidOperationException($"Field '{name}' is missing."); - - /// Builds a delimited document with a header plus data rows. - internal static string DelimitedDocument(int rows) => - string.Join('\n', Enumerable.Range(0, rows + 1).Select(i => i == 0 ? "id,name" : $"{i},row{i}")); -} diff --git a/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs b/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs index a29468b..255a1a1 100644 --- a/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs +++ b/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs @@ -56,7 +56,6 @@ public sealed class JsonSettingsServiceTests : IDisposable """ { "theme": "System", - "lastParserId": "delimited", "windowWidth": 1280, "windowHeight": 800, "windowMaximized": false, @@ -76,7 +75,6 @@ public sealed class JsonSettingsServiceTests : IDisposable settings.Language.ShouldBe(AppLanguage.System); // And the values the file did carry must survive. - settings.LastParserId.ShouldBe("delimited"); settings.MinimumLogLevel.ShouldBe("Information"); } diff --git a/tests/AvParser.UI.HeadlessTests/LocalizationViewTests.cs b/tests/AvParser.UI.HeadlessTests/LocalizationViewTests.cs index e8299f2..8b11528 100644 --- a/tests/AvParser.UI.HeadlessTests/LocalizationViewTests.cs +++ b/tests/AvParser.UI.HeadlessTests/LocalizationViewTests.cs @@ -19,7 +19,7 @@ public sealed class LocalizationViewTests : IDisposable private static (ShellView View, ShellViewModel ViewModel) ShowShell() { - var navigation = new NavigationService([new FakePage("Page.Dashboard"), new FakePage("Page.Parse")]); + var navigation = new NavigationService([new FakePage("Page.Dashboard"), new FakePage("Page.Collect")]); var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance); var view = new ShellView { DataContext = viewModel }; @@ -49,14 +49,14 @@ public sealed class LocalizationViewTests : IDisposable var (view, _) = ShowShell(); var english = RailLabels(view); - english.ShouldBe(["Dashboard", "Parse"]); + english.ShouldBe(["Dashboard", "Collect"]); Localizer.Instance.SetLanguage(AppLanguage.Russian); Dispatcher.UIThread.RunJobs(); // The same controls, not a rebuilt tree: this is the whole point of binding through the // localizer's indexer rather than resolving strings once at load. - RailLabels(view).ShouldBe(["Обзор", "Разбор"]); + RailLabels(view).ShouldBe(["Обзор", "Сбор"]); } [AvaloniaFact] diff --git a/tests/AvParser.UI.HeadlessTests/ParseViewTests.cs b/tests/AvParser.UI.HeadlessTests/ParseViewTests.cs deleted file mode 100644 index 0956270..0000000 --- a/tests/AvParser.UI.HeadlessTests/ParseViewTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Headless.XUnit; -using Avalonia.Threading; -using Avalonia.VisualTree; -using AvParser.Core.Parsing; -using AvParser.Core.Parsing.Samples; -using AvParser.Core.Proxies; -using AvParser.Core.Settings; -using AvParser.UI.ViewModels; -using AvParser.UI.Views; -using Microsoft.Extensions.Logging.Abstractions; -using ReactiveUI.Primitives.Concurrency; - -namespace AvParser.UI.HeadlessTests; - -public class ParseViewTests -{ - private static (ParseView View, ParseViewModel ViewModel, Window Window) ShowPage(params ITextParser[] extra) - { - var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser(), .. extra]); - var lastParser = extra.Length > 0 ? extra[0].Id : null; - - var viewModel = new ParseViewModel( - catalog, - new FakeSettingsService(new AppSettings { LastParserId = lastParser }), - new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()), - new EmptyServiceProvider(), - NullLogger.Instance, - ImmediateSequencer.Instance - ); - - var view = new ParseView { DataContext = viewModel }; - var window = new Window - { - Width = 1400, - Height = 900, - Content = view, - }; - - window.Show(); - Dispatcher.UIThread.RunJobs(); - - return (view, viewModel, window); - } - - private static Border Banner(ParseView view) => view.FindControl("ProxyGateBanner").ShouldNotBeNull(); - - [AvaloniaFact] - public void No_banner_is_shown_for_a_parser_that_needs_no_network() - { - var (view, viewModel, _) = ShowPage(); - - viewModel.IsBlockedWithoutProxy.ShouldBeFalse(); - Banner(view).IsVisible.ShouldBeFalse(); - } - - [AvaloniaFact] - public void A_blocked_network_parser_puts_the_banner_on_screen() - { - // Rendered rather than asserted on the view model: an IsVisible binding that never fires - // leaves the page silently unhelpful, which is exactly the failure this guards. - var (view, viewModel, _) = ShowPage(new NetworkParser()); - Dispatcher.UIThread.RunJobs(); - - viewModel.IsBlockedWithoutProxy.ShouldBeTrue(); - Banner(view).IsEffectivelyVisible.ShouldBeTrue(); - } - - [AvaloniaFact] - public void The_banner_offers_a_way_to_the_proxies_page() - { - var (view, viewModel, _) = ShowPage(new NetworkParser()); - Dispatcher.UIThread.RunJobs(); - - var button = Banner(view).GetVisualDescendants().OfType