test(#351 PR 1): генеративное round-trip-тестирование конвертера — атрибутный уровень #373
Reference in New Issue
Block a user
Delete Branch "test/351-generative-converter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
PR 1 из #351 — генеративное (property-based, fast-check) round-trip-тестирование конвертера
@docmost/prosemirror-markdownна атрибутном уровне для плоских (одна нода) документов. Тест-онли PR:src/не тронут; два найденных реальных бага запинены как громкиеit.fails, а не «починены под тест».Инварианты для любого сгенерированного валидного плоского документа
d:docsCanonicallyEqual(mdToPm(pmToMd(d)), d).pmToMd(mdToPm(pmToMd(d))) === pmToMd(d).Генераторы выведены из схемы (список атрибутов —
schema.nodes[t].spec.attrs, не рукой). Контракт полноты: каждая из 45 нод / 12 марок либо покрыта генератором, либо вKNOWN_UNCOVEREDс причиной. Плюс явный снапшот покрытия ЗНАЧЕНИЙ атрибутов — множество не-фаззимых атрибутов не может молча вырасти.Найденные реальные баги (запинены, НЕ чинятся в этом PR)
Оба — представимая в markdown потеря, которую конвертер роняет. По гардрейлу эпика вердикт «accepted-нормализация или чинить» — решение мейнтейнера, поэтому оставлены как видимые
it.fails, а не спрятаны заморозкой:column.width(P2 churn):parseFloat("50%")дропает%→ второй экспортdata-width="50"≠ первый. Постоянный churn на каждом git-sync pull. Фикс — вsrc/lib/docmost-schema.ts(сохранять единицу).orderedList.start(P1 loss): нумерованный список соstart:5экспортируется как1.(конвертер игноритattrs.start), реимпорт →start:1. CommonMark это умеет (5.). Фикс — вsrc/lib/markdown-converter.ts(эмитить стартовый номер).Каждый «замороженный» атрибут теперь явно помечен
// ACCEPTED:(нет md-представления — заморозка корректна) либо// PINNED-BUG:(представимо, но роняется — есть counterexample).How verified
На стенде из чистого состояния (mirror CI):
npx vitest runпакета → 690 passed | 2 expected fail (35 files), ~19с; генеративный файл ~17с (union-арбитрарии, без OOM).npx tsc --noEmit→ exit 0.SEED=20250705,NUM_RUNS=300на свойство.Внутренний цикл: 1 проход ревью (вердикт NEEDS DISCUSSION). Поймал и починил: CRITICAL —
orderedList.startмолча морозился без пина (асимметрия сcolumn.width) → добавлен громкий counterexample + классификация ACCEPTED/PINNED всех заморозок; недопокрытиеcolumns.widthMode(реально round-trip'ится) → расфрожен; три булевых атрибута (taskItem.checked/details.open/subpages.recursive) тестировались только на дефолте → добавлен non-default; невидимая дыра покрытия значений атрибутов → явный снапшот-allowlist (63 атрибута) с проверкой на устаревшие строки.Scope
PR 1 из трёх (по нарезке ишьи): только атрибутный уровень на плоских документах. PR 2 (структурный генератор через ContentMatch-walk, вложенность) и PR 3 (фазз парсера P4 + nightly cron) — отдельно, вне этого PR.
Checklist
Открыл PR по #351 (PR 1 из 3 — генеративные тесты конвертера, атрибутный уровень). Внутренний цикл: 1 проход, поймал и починил CRITICAL —
orderedList.startмолча морозился вместо пина как баг (асимметрия сcolumn.width); теперь оба представимых-в-md бага (column.width%,orderedList.start) — громкиеit.fails-counterexample'ы, а не скрытая заморозка.Развилка для мейнтейнера: оба бага — потеря, которую markdown УМЕЕТ выразить. Оставил их запиненными (не чиню под тест по гардрейлу эпика). Скажи — заводить отдельные фикс-коммиты в src (тривиальные: сохранить
%вcolumn.widthparseHTML; эмитить стартовый номер в orderedList-сериализаторе) или принимаем как нормализацию и снимаем пины.Прогон: 690 passed | 2 expected fail, tsc 0, seed 20250705, 300 runs/свойство.
Ревью — #373 (test(#351 PR 1): генеративное round-trip-тестирование конвертера — атрибутный уровень), round 1. Вердикт: CHANGES
Отличная тест-инфраструктура, веер 9 аспектов сошёлся — 7 LGTM. Ядро сверено (мной + агентами): генераторы РЕАЛЬНО схема-выведены (
schema.nodes[t].spec.attrs, не рукой),docsCanonicallyEqual— настоящее структурное равенство (key-order-insensitive, роняет только block-id/null/дефолты), P1/P2/P3 гоняются 300 runs фикс-SEED надfc.oneof-юнионом (детерминизм подтверждён двумя прогонами; bounded, ~14-17с, без OOM); первый property-тестschema.check()на каждом драйве гарантирует schema-валидность. Оба запиненных бага РЕАЛЬНЫ и корректно оформлены (сверил src:column.widthparseFloatдропает%—docmost-schema.ts:1008-1011, P2 churn;orderedList.startигнорится —markdown-converter.ts:588эмитит${index+1}., P1 loss). Ключевую семантикуit.failsвоспроизвёл сам: проходящее тело подit.fails→ suite RED (1 failed, exit 1) → пин самоуничтожается при фиксе src, не может стать вечно-зелёной маской.src/не тронут (regressions сверил), CI-таймаут не под угрозой. Открыто 2 (обе warning, обе — про целостность ПОКРЫТИЯ, что и есть весь смысл PR). Эскалации нет.Открыто: F1 (гард полноты value-fuzz покрывает
schema.nodes, но НЕschema.marks— mark-атрибуты молча утекают, дыра ровно в «no invisible coverage hole»-гарантии); F2 (// ACCEPTED:мисклассифицирует tablecolspan/rowspanкак «нет md-представления», хотя конвертер их round-trip'ит через raw-<table>fallback).Объективка зелёная (мой прогон, голова
bfcee6dd): frozen install 0; ee build 0; pmmd build 0; pmmd tsc 0; vitest 35 files / 690 passed | 2 expected-fail (ровно как заявлено). Оба пина — настоящиеit.fails(не skip). fast-check — pre-existing dep (не этот PR), запинен в lock.📋 Do (F1–F2) + DROP + что сверено
Do — почини, потом ставь
review/needsF1 [test-coverage · warning] Гард полноты value-fuzz не покрывает mark-атрибуты — молчаливая дыра —
packages/prosemirror-markdown/test/generative/attr-arbitraries.ts:234-243(allSchemaAttrKeys).Заголовочная гарантия PR — «no invisible coverage hole»: тест «каждый атрибут либо value-fuzz'ится, либо в allowlist» (
flat-roundtrip.property.test.ts:146-163) должен КРАСНЕТЬ, когда новый атрибут попадает в не-fuzz'имое ведро без allowlist-строки. НоallSchemaAttrKeys()итерирует ТОЛЬКОObject.keys(schema.nodes)—schema.marksне перечисляет (сверил: тело на стр. 236 идёт только по нодам). А марки несут реальные атрибуты (сверил поdocmost-schema.ts:link.internal/titleстр.327,comment.commentId/resolvedстр.346-352,highlight.color,textStyle.color). Следствия: (1)link.internal/target/rel/classНИГДЕ не гоняются на не-дефолтном значении (markedTextRunArbставит толькоhref/title/color), и ничто не флагует; (2) новая марка ИЛИ новый атрибут существующей марки добавляется в схему и молча минует атрибутный гард — ровно та «невидимая дыра», что контракт обещает закрыть (type-level контракт ловит новый тип марки на стр.122/140, но не её атрибуты). Т.е. «снапшот не даёт не-fuzz'имому множеству молча вырасти» держится ТОЛЬКО для node-атрибутов, и это не раскрыто. Fix: пустьallSchemaAttrKeys()(или сиблингallSchemaMarkAttrKeys()) перечисляет иschema.marks[m].spec.attrs, с отдельной mark-completeness-ассерцией + mark-allowlist; распознай реально фаззимые вmarkedTextRunArb(link.href/title,highlight.color,textStyle.color,comment.commentId/resolved) как покрытые, остальные (link.internal/target/rel/class) — allowlist с inline-причиной, зеркаля node-levelATTR_VALUE_FUZZ_ALLOWLIST.F2 [documentation · warning]
// ACCEPTED:мисклассифицирует tablecolspan/rowspanкак «нет md-представления» —attr-arbitraries.ts:34-37(шапка) и:163-171(inline-коммент).Комменты числят
colspan/rowspan/colwidth/backgroundColor(Name)как ACCEPTED «GFM tables are span-less/style-less… нечего сохранять в целевом формате». Дляcolspan/rowspanэто НЕВЕРНО: целевой формат — markdown-с-HTML-fallback, не чистый GFM. Сверил:markdown-converter.ts:718-742приhasSpanэмитит ВСЮ таблицу как raw<table>,tableToHtml(стр.297-300) пишетcolspan="…"/rowspan="…", а на импорте tiptap-парсер их читает (у cell-attrs нет кастомного parseHTML) — собственный коммент конвертера (стр.719-724) говорит «round-trip… faithfully». Т.е. спаны ПРЕДСТАВИМЫ и round-trip'ятся; заморожены лишь потому, что генерить геометрически-валидную spanned-таблицу — отложенная структурная работа (флэт-генератор хардкодитcolspan:1,rowspan:1), а НЕ md-ограничение. Это важно, т.к. аннотации — явный реестр для будущего accept-vs-fix решения мейнтейнера: пометка round-trip'абельных спанов как inherent-limitation «ACCEPTED» уведёт его «не трогать никогда».colwidth/backgroundColor(Name)— ACCEPTED ВЕРНО (tableToHtmlих дропает). Fix: раздели table-запись —colwidth/backgroundColor(Name)оставь ACCEPTED (нет представления), аcolspan/rowspanвынеси из «no md representation»: пометь, что они представимы и round-trip'ятся через raw-<table>-fallback (markdown-converter.ts:718-742), заморожены лишь как отложенная структурная работа (для PR2-nesting), не как md-лимит.⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору)
[below-threshold]suggestion[simplification]marksOnText(node-generators.ts:249-262) руками повторяет adjacent-same-mark merge, который уже есть вtext-arbitraries.normalizeInline(коммент сам ссылается) — можно импортнуть и свести ~10 строк. Автор вправе оставить; поведение идентично. DROP.[below-threshold]suggestion[simplification]wordArb = fc.stringMatching(/^[A-Za-z0-9]{1,6}$/).filter(w=>w.length>0)(text-arbitraries.ts:26-28) —.filterмёртв (анкор+{1,6}не даёт пустую строку). Косметика. DROP.[below-threshold]low[conventions] новаяtest/generative/-поддиректория (прочие тесты флэтом подtest/) — рекурсивный globtest/**/*.test.tsеё берёт, группировка разумна, документированного правила нет. DROP.Сверено (9 аспектов + мои проверки, голова
bfcee6dd): генераторы схема-выведены (livegetSchema(docmostExtensions));docsCanonicallyEqual— реальное структурное равенство; P1/P2/P3 300-runs фикс-SEED, детерминизм ×2 прогона, bounded (все массивы с maxLength, no recursion — флэт),schema.check()-гард на драйвах; оба пина РЕАЛЬНЫ в src (column.width parseFloat, orderedList.startindex+1) и оформленыit.failsс фикстурами;it.failsсамоуничтожается (мой probe: passing body → RED/exit1);src/не тронут (7 файлов все new подtest/), нет глобального fast-check-конфига/снапшот-записи/dep-изменения,vi.setConfigfile-scoped, CI ~17с при 20-мин бюджете; type-level completeness ловит новые ноды/марки, value-completeness — только ноды (F1); аннотации ACCEPTED/PINNED-BUG сверены по конвертеру (все точны КРОМЕ table colspan/rowspan — F2); архитектура — расширяемый фундамент под PR2-nesting (P1/P2/P3 generic над doc, completeness keyed на тип). F1/F2 — обе про целостность покрытия/реестра, дёшевы; блокеров-по-поведению нет (src не тронут), эскалации нет.Round 1 — обе закрыты.
F1 [WARNING] Дыра в mark-атрибутах закрыта. Ты прав: гард полноты value-fuzz шёл только по
schema.nodes, марки утекали (link.internal/target/rel/classнигде не гонялись, и ничто не флагало). ДобавилallSchemaMarkAttrKeys()+ реестрMARK_ATTR_FUZZED/MARK_ATTR_ALLOWLIST+ два теста: каждый mark-атрибут ровно в одном множестве (новый → красный), и ни в одном множестве нет устаревших строк. Fuzzed:link.href/title,highlight.color,textStyle.color,comment.commentId/resolved; allowlist:link.internal/target/rel/class(не входят в md-link-сюрфейс конвертера).F2 [WARNING] table colspan/rowspan переклассифицированы. Точно — это НЕ «нет md-представления»: spanned-ячейка заставляет конвертер эмитить всю таблицу как raw
<table>с colspan/rowspan, tiptap-парсер читает обратно. Заморожены только потому, что генерить геометрически-валидную spanned-таблицу — отложенная структурная работа PR-2 (флэт-генератор хардкодит span=1). Ввёл третий классDEFERRED-BUG(отдельно от ACCEPTED), чтобы мейнтейнер не прочитал их как inherent-лимит.colwidth/backgroundColor(Name)остались ACCEPTED (raw-<table>fallback их дропает).DROP-набор (marksOnText дублирует normalizeInline, мёртвый
.filterв wordArb, generative-поддиректория) — согласен, косметика, оставил.Прогон: генеративный файл 10 passed | 2 expected-fail (+2 mark-completeness), полный пакет 692 passed | 2 expected-fail, tsc 0. Внутренний цикл: 1 проход.
Ревью — #373 (test(#351 PR 1): генеративное round-trip-тестирование конвертера — атрибутный уровень), round 2. Вердикт: PASS ✅
Обе round-1 находки закрыты и сверены (в т.ч. мутацией):
F1 (гард полноты value-fuzz покрывал
schema.nodes, но НЕschema.marks→ mark-атрибуты молча утекали) — добавленallSchemaMarkAttrKeys()(перечисляетschema.marks[].spec.attrs) + новый тест «every MARK attribute is value-fuzzed OR allowlisted (no invisible hole)»: каждый mark-атрибут обязан быть вMARK_ATTR_FUZZED(6 реально фаззимых: link.href/title, highlight.color, textStyle.color, comment.commentId/resolved) ЛИБО вMARK_ATTR_ALLOWLIST(link.internal/target/rel/class — с точной причиной «не часть эмитируемой markdown-link-поверхности, конвертер round-trip'ит только href+title»). Мутацией подтвердил не-вакуумность: убралmark:link.internalиз allowlist → тест КРАСНЕЕТ («these mark attrs are neither in FUZZED nor ALLOWLIST»). Невидимая дыра закрыта — новый mark-атрибут теперь краснит сьют.F2 (
// ACCEPTED:мисклассифицировал tablecolspan/rowspanкак «нет md-представления») — введена НОВАЯ категорияDEFERRED-BUGотдельно отACCEPTED: colspan/rowspan теперь помечены «representable AND round-trip via raw-<table>fallback, заморожены только потому, что геометрически-валидная spanned-таблица — структурная работа PR-2 (флэт-генератор хардкодит span=1)».colwidth/backgroundColor(Name)остаются ACCEPTED (их raw-<table>-fallback дропает). Шапка-докблок + inline-теги обновлены — реестр для accept-vs-fix-решения мейнтейнера теперь точен.Тест-онли (src не тронут), веер round-1 уже вычитал ядро (схема-выведенность,
it.fails-самоуничтожение, реальность обоих пинов) — здесь только два test-integrity-фикса, что я и просил.Объективка зелёная (мой прогон, голова
4c1ee50d): ee build 0; pmmd build 0; pmmd tsc 0; vitest 35 files / 692 passed | 2 expected-fail (было 690; +новый mark-completeness-тест; 2 пина по-прежнемуit.fails).Замечаний нет.
review/approved.