diff --git a/src/author/proposal.ts b/src/author/proposal.ts index 16b48b0..5096ab0 100644 --- a/src/author/proposal.ts +++ b/src/author/proposal.ts @@ -120,12 +120,20 @@ function bindContent(node: AuthoringProposalNode, element: Element, attributes: const apply = (attribute: string, value: JsonValue): void => { const change = decision(attribute); if (change?.action === 'omit') return; - if (attributes[attribute] !== undefined && JSON.stringify(attributes[attribute]) !== JSON.stringify(value) && !change) throw new Error(`proposal ${node.id}.${attribute} changes source content without an explicit source decision`); + if (attributes[attribute] !== undefined && !sourceAttributeMatchesProposal(node.block, attribute, attributes[attribute], value) && !change) throw new Error(`proposal ${node.id}.${attribute} changes source content without an explicit source decision`); attributes[attribute] = change?.action === 'replace' || change?.action === 'add' ? change.value! : value; }; for (const [attribute, value] of sourceAttributes(element, node.block)) apply(attribute, value); } +/** Only native image dimensions accept their numeric proposal form; canonical plans retain source strings. */ +function sourceAttributeMatchesProposal(block: string, attribute: string, proposed: JsonValue, source: JsonValue): boolean { + if (JSON.stringify(proposed) === JSON.stringify(source)) return true; + return block === 'core/image' && (attribute === 'width' || attribute === 'height') + && typeof proposed === 'number' && Number.isSafeInteger(proposed) && proposed > 0 + && source === String(proposed); +} + function safeHtml(element: Element): string { const safe = richTextSafe(element); if (!safe.safe) throw new Error(`source content is not RichText-safe: ${safe.reason}`); return cleanRichText(element).html; } function ownsContent(block: string): boolean { return ['core/image', 'core/button', 'core/heading', 'core/paragraph', 'core/list-item'].includes(block); } function sourceLocation(element: Element | undefined, dom: JSDOM, path?: string): AuthoringCoverageLocation | undefined { const loc = element && dom.nodeLocation(element); return loc ? { path, htmlLine: loc.startLine, htmlColumn: loc.startCol, offset: loc.startOffset } : undefined; } diff --git a/test/author.proposal.test.ts b/test/author.proposal.test.ts index 77ec57f..bc18182 100644 --- a/test/author.proposal.test.ts +++ b/test/author.proposal.test.ts @@ -15,7 +15,7 @@ import { writeGeneratedRegisteredBlock, } from '../src/index.js'; import { validateSourceContent } from '../src/author/content.js'; -import type { AuthoringStructureNode } from '../src/authoring/schema.js'; +import type { AuthoringStructureNode, JsonValue } from '../src/authoring/schema.js'; const benchmarkRoot = path.resolve('benchmarks/authoring/sources'); async function source(relative: string): Promise<{ html: string; sourcePath: string }> { @@ -136,6 +136,51 @@ describe('author proposal boundary', () => { expect(report.package!.canonicalPlan!.sourceDecisions![0]).toMatchObject({ action: 'replace', original: 'Original copy', value: 'Revised copy' }); }); + it.each([ + ['direct image', 'Hero', 'img'], + ['figure image', '
Hero
', 'figure'], + ])('binds %s dimensions from equivalent numeric proposal values as source strings', async (_name, html, tag) => { + const reference = refs(html)(tag); + const report = await author(html, { author: { name: 'example/proposal' }, proposal: { + structure: [{ id: 'image', block: 'core/image', sourceRef: reference, attributes: { width: 640, height: 480 } }], + } }); + expect(report.ok, JSON.stringify(report.items)).toBe(true); + expect(report.package!.canonicalPlan!.structure[0]!.attributes).toMatchObject({ width: '640', height: '480' }); + expect(report.package!.canonicalPlan!.sourceDecisions).toBeUndefined(); + const omitted = await author(html, { author: { name: 'example/proposal' }, proposal: { + structure: [{ id: 'image', block: 'core/image', sourceRef: reference, attributes: { width: 640 } }], + sourceDecisions: [{ action: 'omit', sourceRef: reference, node: 'image', attribute: 'height', reason: 'Height is intentionally omitted.' }], + } }); + expect(omitted.ok, JSON.stringify(omitted.items)).toBe(true); + expect(omitted.package!.canonicalPlan!.structure[0]!.attributes).toMatchObject({ width: '640' }); + expect(omitted.package!.canonicalPlan!.structure[0]!.attributes).not.toHaveProperty('height'); + expect(omitted.package!.canonicalPlan!.sourceDecisions).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'omit', attribute: 'height', original: '480' }), + ])); + }); + + it('requires decisions for changed or invalid image dimension proposal values', async () => { + const html = 'Hero'; + const reference = refs(html)('img'); + const proposal = (width: JsonValue, height: JsonValue) => ({ structure: [{ id: 'image', block: 'core/image', sourceRef: reference, attributes: { width, height } }] }); + const changed = await author(html, { author: { name: 'example/proposal' }, proposal: proposal(641, 480) }); + expect(changed.ok).toBe(false); + expect(changed.items.map((item) => item.reason).join('\n')).toMatch(/changes source content without an explicit source decision/i); + for (const value of [640.5, 0, -640, Infinity, true, {}, Number.MAX_SAFE_INTEGER + 1, '640px']) { + const rejected = await author(html, { author: { name: 'example/proposal' }, proposal: proposal(value, 480) }); + expect(rejected.ok, `${String(value)}: ${JSON.stringify(rejected.items)}`).toBe(false); + } + const reviewed = await author(html, { author: { name: 'example/proposal' }, proposal: { + ...proposal('641', 480), + sourceDecisions: [{ action: 'replace', sourceRef: reference, node: 'image', attribute: 'width', value: '641', reason: 'Approved image crop.' }], + } }); + expect(reviewed.ok, JSON.stringify(reviewed.items)).toBe(true); + expect(reviewed.package!.canonicalPlan!.structure[0]!.attributes).toMatchObject({ width: '641', height: '480' }); + expect(reviewed.package!.canonicalPlan!.sourceDecisions).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'replace', attribute: 'width', original: '640', value: '641' }), + ])); + }); + it('accounts for identical source units by reference and audits href aliases', async () => { const html = '

Same

Same

Discard

Read'; const evidence = collectSourceEvidence(html).structure;