test(converter): value-fuzz the deferred media attributes (#351)

Item 4 of the #351 remainder — implement value-fuzz for the media
dimension/family attributes previously parked in the flat property
suite's ATTR_VALUE_FUZZ_ALLOWLIST as "round-trip candidates deferred to
a later PR": width/height/align/aspectRatio/size/caption/title/alt across
image, video, youtube, pdf, drawio, excalidraw, embed.

Each gets a non-default-value arbitrary in attr-arbitraries.ts and is
removed from the allowlist; the P1 (semantic) + P2 (byte-stability)
property gate now exercises them. All round-trip green — the comment-JSON
serialization (stable key order, String()-stringified) carries every one
faithfully, so no new pins or accepted-limitations were needed.

embed.width/height are fuzzed as numeric STRINGS (not numbers): a
non-default embed dimension round-trips through the comment JSON as a
string (the numeric 800/600 default is still omitted/re-materialized),
so authoring a number diverged under P1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 19:40:52 +03:00
parent 54f0ba681e
commit 15fe998d3d
2 changed files with 72 additions and 15 deletions
@@ -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<string, AttrPolicy> = {
'image.title': { arb: letterPhraseArb },
'image.width': { arb: widthStr, degen: '' },
'image.height': { arb: widthStr, degen: '' },
// #351 (this PR): media sizing/family attrs ride in the `<!--img {…}-->`
// 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 `<!--video {…}-->` 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 `<!--youtube {…}-->`
// 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 `<!--pdf {…}-->` 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 `<!--drawio {…}-->` 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 `<!--excalidraw {…}-->`
// 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<string, AttrPolicy> = {
// ── 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: '' },
@@ -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<string>([
'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 ───────────────────────────────────────────