diff --git a/packages/prosemirror-markdown/test/generative/attr-arbitraries.ts b/packages/prosemirror-markdown/test/generative/attr-arbitraries.ts index b0f88fd4..c3c9a03d 100644 --- a/packages/prosemirror-markdown/test/generative/attr-arbitraries.ts +++ b/packages/prosemirror-markdown/test/generative/attr-arbitraries.ts @@ -120,6 +120,11 @@ interface AttrPolicy { const num = (...xs: number[]) => fc.constantFrom(...xs); const str = (...xs: string[]) => fc.constantFrom(...xs); const widthStr = str('120', '320', '640'); +// Media `aspectRatio`/`size` are stringified numerics too (converter emits +// String(value)); the schema parseHTML reads them back as strings. Fuzz as +// plausible numeric strings so they round-trip byte-stably like widthStr. +const aspectRatioStr = str('1.5', '0.75', '1'); +const sizeStr = str('320', '640'); // The documented override table, keyed `type.attr`. Every entry is grounded in // the empirical converter probe (see flat-roundtrip.property.test.ts header). @@ -149,16 +154,57 @@ const OVERRIDES: Record = { 'image.title': { arb: letterPhraseArb }, 'image.width': { arb: widthStr, degen: '' }, 'image.height': { arb: widthStr, degen: '' }, + // #351 (this PR): media sizing/family attrs ride in the `` + // comment JSON via String(value); parseHTML reads them back as strings, so a + // numeric string round-trips byte-stably (mirrors image.width). + 'image.size': { arb: sizeStr, degen: '' }, + 'image.aspectRatio': { arb: aspectRatioStr }, + // caption is text carried verbatim in the comment JSON (mirrors image.alt). + 'image.caption': { arb: letterPhraseArb, degen: '' }, 'video.src': { noDefault: true, arb: urlArb, degen: '' }, 'video.alt': { arb: letterPhraseArb }, 'video.width': { arb: widthStr }, 'video.height': { arb: widthStr }, + // #351 (this PR): video sizing/align ride in the `` comment. + 'video.size': { arb: sizeStr }, + 'video.aspectRatio': { arb: aspectRatioStr }, + // align default "center" is dropped on export; fuzz non-center only. + 'video.align': { arb: str('left', 'right') }, 'audio.src': { noDefault: true, arb: urlArb, degen: '' }, 'youtube.src': { noDefault: true, arb: urlArb }, + // #351 (this PR): youtube width/height/align ride in the `` + // comment JSON (String()-coerced dimensions, non-center align only). + 'youtube.width': { arb: widthStr }, + 'youtube.height': { arb: widthStr }, + 'youtube.align': { arb: str('left', 'right') }, 'pdf.src': { noDefault: true, arb: urlArb }, 'pdf.name': { arb: phraseArb }, + // #351 (this PR): pdf size/width/height ride in the `` comment + // (String()-coerced dimensions, read back as strings). + 'pdf.size': { arb: sizeStr }, + 'pdf.width': { arb: widthStr }, + 'pdf.height': { arb: widthStr }, 'drawio.src': { noDefault: true, arb: urlArb }, + // #351 (this PR): drawio family attrs ride in the `` comment. + // Dimensions/size/aspectRatio are String()-coerced numeric strings; title/alt + // are text carried verbatim; align fuzzed non-center only. + 'drawio.width': { arb: widthStr }, + 'drawio.height': { arb: widthStr }, + 'drawio.size': { arb: sizeStr }, + 'drawio.aspectRatio': { arb: aspectRatioStr }, + 'drawio.align': { arb: str('left', 'right') }, + 'drawio.title': { arb: letterPhraseArb }, + 'drawio.alt': { arb: letterPhraseArb }, 'excalidraw.src': { noDefault: true, arb: urlArb }, + // #351 (this PR): excalidraw family attrs ride in the `` + // comment (same shape as the drawio family above). + 'excalidraw.width': { arb: widthStr }, + 'excalidraw.height': { arb: widthStr }, + 'excalidraw.size': { arb: sizeStr }, + 'excalidraw.aspectRatio': { arb: aspectRatioStr }, + 'excalidraw.align': { arb: str('left', 'right') }, + 'excalidraw.title': { arb: letterPhraseArb }, + 'excalidraw.alt': { arb: letterPhraseArb }, 'attachment.url': { noDefault: true, arb: urlArb }, 'attachment.name': { arb: phraseArb }, // ── callout / status ───────────────────────────────────────────────────── @@ -201,8 +247,19 @@ const OVERRIDES: Record = { // ── embed (schema keeps width/height NUMERIC, not string-coerced) ───────── 'embed.src': { noDefault: true, arb: urlArb, degen: '' }, 'embed.provider': { noDefault: true, arb: str('iframe', 'youtube', 'vimeo') }, - 'embed.width': { always: true, frozen: true }, - 'embed.height': { always: true, frozen: true }, + // #351 (this PR): the embed schema defaults width/height to the NUMBERS 800/600 + // and the converter only emits them when they differ. But the value round-trips + // as a STRING: export stringifies into the comment JSON (String(width)) and the + // import path (embedToHtml -> data-width -> embed parseHTML) reads it back as a + // string, so an authored NUMBER 400 would diverge under P1 (400 vs "400"). Fuzz + // as numeric STRINGS avoiding "800"/"600" so they round-trip byte-stably. The + // 800/600 numeric default state still round-trips (omitted on export, re- + // materialized as the numeric default). `always` stays because these are + // materialized on import but absent from canonicalize's KNOWN_DEFAULTS. + 'embed.width': { always: true, arb: str('400', '1000', '1200') }, + 'embed.height': { always: true, arb: str('300', '500', '900') }, + // align default "center" is dropped on export; fuzz non-center only. + 'embed.align': { arb: str('left', 'right') }, // ── subpages / math / htmlEmbed ────────────────────────────────────────── 'subpages.recursive': { always: true, arb: fc.constant(true) }, // boolean, default false 'mathBlock.text': { noDefault: true, arb: str('x^2', 'a < b', '\\frac{1}{2}'), degen: '' }, diff --git a/packages/prosemirror-markdown/test/generative/flat-roundtrip.property.test.ts b/packages/prosemirror-markdown/test/generative/flat-roundtrip.property.test.ts index 5be0631c..dfdd2b8c 100644 --- a/packages/prosemirror-markdown/test/generative/flat-roundtrip.property.test.ts +++ b/packages/prosemirror-markdown/test/generative/flat-roundtrip.property.test.ts @@ -28,32 +28,32 @@ import { // a NEW attribute (or a newly-frozen one) that lands in this bucket flips the // snapshot test red and forces a reviewer to classify it. Each belongs to one of: // - internal/opaque ids & placeholders (attachmentId, slugId, placeholder, -// creatorId, anchorId) — no meaningful non-default to assert; -// - dimensions/among the media family with no standalone md form here -// (aspectRatio, size, caption, drawio/excalidraw/pdf/video/youtube w/h/align) -// — round-trip candidates deferred to a later PR, not silently dropped; +// creatorId, anchorId, mime) — no meaningful non-default to assert. These stay +// frozen: their value is an opaque token carried verbatim, not a round-trip +// shape worth fuzzing; // - ACCEPTED limitations with no md representation (indent, callout.icon, // orderedList.type, table spans/bg/colwidth). +// The media dimension/family attrs (image/video/youtube/drawio/excalidraw/pdf/ +// embed width/height/align/size/aspectRatio/caption/title/alt) that were +// previously "deferred to a later PR" are IMPLEMENTED (value-fuzzed) in THIS PR +// via the OVERRIDES table in attr-arbitraries.ts — they ride in the discriminator +// comment JSON and round-trip byte-stably, so they are no longer allowlisted. const ATTR_VALUE_FUZZ_ALLOWLIST = new Set([ 'attachment.attachmentId', 'attachment.mime', 'attachment.placeholder', 'attachment.size', 'audio.attachmentId', 'audio.placeholder', 'audio.size', 'callout.icon', - 'drawio.align', 'drawio.alt', 'drawio.aspectRatio', 'drawio.attachmentId', - 'drawio.height', 'drawio.size', 'drawio.title', 'drawio.width', - 'embed.align', 'embed.height', 'embed.width', - 'excalidraw.align', 'excalidraw.alt', 'excalidraw.aspectRatio', 'excalidraw.attachmentId', - 'excalidraw.height', 'excalidraw.size', 'excalidraw.title', 'excalidraw.width', + 'drawio.attachmentId', + 'excalidraw.attachmentId', 'heading.indent', - 'image.aspectRatio', 'image.attachmentId', 'image.caption', 'image.placeholder', 'image.size', + 'image.attachmentId', 'image.placeholder', 'mention.anchorId', 'mention.creatorId', 'mention.slugId', 'orderedList.type', 'paragraph.indent', - 'pdf.attachmentId', 'pdf.height', 'pdf.placeholder', 'pdf.size', 'pdf.width', + 'pdf.attachmentId', 'pdf.placeholder', 'tableCell.backgroundColor', 'tableCell.backgroundColorName', 'tableCell.colspan', 'tableCell.colwidth', 'tableCell.rowspan', 'tableHeader.backgroundColor', 'tableHeader.backgroundColorName', 'tableHeader.colspan', 'tableHeader.colwidth', 'tableHeader.rowspan', - 'video.align', 'video.aspectRatio', 'video.attachmentId', 'video.placeholder', 'video.size', - 'youtube.align', 'youtube.height', 'youtube.width', + 'video.attachmentId', 'video.placeholder', ]); // ── MARK attribute-value coverage ───────────────────────────────────────────