@@ -64,6 +64,29 @@ const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }, pos = 0) => {
6464 } ;
6565} ;
6666
67+ /**
68+ * Builds a mock image inline-node token for diff tests.
69+ *
70+ * @param {Record<string, unknown> } attrs Image node attributes.
71+ * @param {number } pos Position offset for the image node.
72+ * @returns {import('./inline-diffing.ts').InlineNodeToken }
73+ */
74+ const buildImageNodeToken = ( attrs = { } , pos = 0 ) => {
75+ const nodeAttrs = { ...attrs } ;
76+ const type = { name : 'image' } ;
77+ return {
78+ kind : 'inlineNode' ,
79+ nodeType : 'image' ,
80+ node : {
81+ type,
82+ attrs : nodeAttrs ,
83+ toJSON : ( ) => ( { type : 'image' , attrs : nodeAttrs } ) ,
84+ } ,
85+ nodeJSON : { type : 'image' , attrs : nodeAttrs } ,
86+ pos,
87+ } ;
88+ } ;
89+
6790/**
6891 * Builds text tokens without offsets for tokenizer assertions.
6992 *
@@ -426,3 +449,131 @@ describe('tokenizeInlineContent', () => {
426449 expect ( tokens [ 5 ] ?. offset ) . toBe ( 16 ) ;
427450 } ) ;
428451} ) ;
452+
453+ describe ( 'image semantic normalization in inline diff' , ( ) => {
454+ it ( 'produces no diff when images differ only in volatile originalAttributes' , ( ) => {
455+ const baseAttrs = {
456+ src : 'image1.png' ,
457+ size : { width : 100 , height : 50 } ,
458+ originalAttributes : {
459+ 'wp14:anchorId' : 'AAAA1111' ,
460+ 'wp14:editId' : 'BBBB2222' ,
461+ cx : '914400' ,
462+ } ,
463+ } ;
464+ const changedAttrs = {
465+ src : 'image1.png' ,
466+ size : { width : 100 , height : 50 } ,
467+ originalAttributes : {
468+ 'wp14:anchorId' : 'CCCC3333' ,
469+ 'wp14:editId' : 'DDDD4444' ,
470+ cx : '914400' ,
471+ } ,
472+ } ;
473+
474+ const oldToken = buildImageNodeToken ( baseAttrs , 5 ) ;
475+ const newToken = buildImageNodeToken ( changedAttrs , 5 ) ;
476+
477+ const diffs = getInlineDiff ( [ oldToken ] , [ newToken ] , 6 ) ;
478+ expect ( diffs ) . toEqual ( [ ] ) ;
479+ } ) ;
480+
481+ it ( 'detects a real image change even when volatile attrs also differ' , ( ) => {
482+ const oldAttrs = {
483+ src : 'old-image.png' ,
484+ originalAttributes : { 'wp14:anchorId' : 'A1' , cx : '100' } ,
485+ } ;
486+ const newAttrs = {
487+ src : 'new-image.png' ,
488+ originalAttributes : { 'wp14:anchorId' : 'A2' , cx : '100' } ,
489+ } ;
490+
491+ const oldToken = buildImageNodeToken ( oldAttrs , 3 ) ;
492+ const newToken = buildImageNodeToken ( newAttrs , 3 ) ;
493+
494+ const diffs = getInlineDiff ( [ oldToken ] , [ newToken ] , 4 ) ;
495+
496+ expect ( diffs ) . toHaveLength ( 1 ) ;
497+ expect ( diffs [ 0 ] . action ) . toBe ( 'modified' ) ;
498+ expect ( diffs [ 0 ] . kind ) . toBe ( 'inlineNode' ) ;
499+ expect ( diffs [ 0 ] . attrsDiff ?. modified ) . toHaveProperty ( 'src' ) ;
500+ } ) ;
501+
502+ it ( 'handles multiple images in one paragraph using type-based pairing' , ( ) => {
503+ const mkImage = ( src , anchorId , pos ) =>
504+ buildImageNodeToken ( { src, originalAttributes : { 'wp14:anchorId' : anchorId , cx : '100' } } , pos ) ;
505+
506+ const oldTokens = [ mkImage ( 'a.png' , 'ID1' , 1 ) , mkImage ( 'b.png' , 'ID2' , 3 ) ] ;
507+ const newTokens = [ mkImage ( 'a.png' , 'ID3' , 1 ) , mkImage ( 'b.png' , 'ID4' , 3 ) ] ;
508+
509+ const diffs = getInlineDiff ( oldTokens , newTokens , 5 ) ;
510+ expect ( diffs ) . toEqual ( [ ] ) ;
511+ } ) ;
512+
513+ it ( 'emits a diff when one of multiple images genuinely changes' , ( ) => {
514+ const mkImage = ( src , anchorId , pos ) =>
515+ buildImageNodeToken ( { src, originalAttributes : { 'wp14:anchorId' : anchorId } } , pos ) ;
516+
517+ const oldTokens = [ mkImage ( 'a.png' , 'ID1' , 1 ) , mkImage ( 'b.png' , 'ID2' , 3 ) ] ;
518+ const newTokens = [ mkImage ( 'a.png' , 'ID3' , 1 ) , mkImage ( 'c.png' , 'ID4' , 3 ) ] ;
519+
520+ const diffs = getInlineDiff ( oldTokens , newTokens , 5 ) ;
521+
522+ expect ( diffs ) . toHaveLength ( 1 ) ;
523+ expect ( diffs [ 0 ] . action ) . toBe ( 'modified' ) ;
524+ expect ( diffs [ 0 ] . attrsDiff ?. modified ) . toHaveProperty ( 'src' ) ;
525+ } ) ;
526+
527+ it ( 'correctly detects an image insertion when a new image is prepended' , ( ) => {
528+ const mkImage = ( src , pos ) => buildImageNodeToken ( { src } , pos ) ;
529+
530+ const oldTokens = [ mkImage ( 'a.png' , 1 ) , mkImage ( 'b.png' , 3 ) ] ;
531+ const newTokens = [ mkImage ( 'x.png' , 1 ) , mkImage ( 'a.png' , 3 ) , mkImage ( 'b.png' , 5 ) ] ;
532+
533+ const diffs = getInlineDiff ( oldTokens , newTokens , 5 ) ;
534+
535+ // Should be a single insertion of x.png, not two modifications + addition
536+ expect ( diffs ) . toHaveLength ( 1 ) ;
537+ expect ( diffs [ 0 ] . action ) . toBe ( 'added' ) ;
538+ expect ( diffs [ 0 ] . kind ) . toBe ( 'inlineNode' ) ;
539+ expect ( diffs [ 0 ] . nodeJSON . attrs . src ) . toBe ( 'x.png' ) ;
540+ } ) ;
541+
542+ it ( 'correctly detects image reordering as delete + add' , ( ) => {
543+ const mkImage = ( src , pos ) => buildImageNodeToken ( { src } , pos ) ;
544+
545+ const oldTokens = [ mkImage ( 'a.png' , 1 ) , mkImage ( 'b.png' , 3 ) ] ;
546+ const newTokens = [ mkImage ( 'b.png' , 1 ) , mkImage ( 'a.png' , 3 ) ] ;
547+
548+ const diffs = getInlineDiff ( oldTokens , newTokens , 5 ) ;
549+
550+ // Reorder produces diffs — at minimum some combination of added/deleted
551+ expect ( diffs . length ) . toBeGreaterThan ( 0 ) ;
552+ } ) ;
553+
554+ it ( 'excludes volatile attrs from attrsDiff when a real image change occurs' , ( ) => {
555+ const oldAttrs = {
556+ src : 'v1.png' ,
557+ size : { width : 100 } ,
558+ originalAttributes : { 'wp14:anchorId' : 'OLD' , 'wp14:editId' : 'OLD' , cx : '100' } ,
559+ } ;
560+ const newAttrs = {
561+ src : 'v2.png' ,
562+ size : { width : 200 } ,
563+ originalAttributes : { 'wp14:anchorId' : 'NEW' , 'wp14:editId' : 'NEW' , cx : '100' } ,
564+ } ;
565+
566+ const diffs = getInlineDiff ( [ buildImageNodeToken ( oldAttrs , 1 ) ] , [ buildImageNodeToken ( newAttrs , 1 ) ] , 2 ) ;
567+
568+ expect ( diffs ) . toHaveLength ( 1 ) ;
569+ const attrsDiff = diffs [ 0 ] . attrsDiff ;
570+
571+ // Semantic changes are reported
572+ expect ( attrsDiff ?. modified ) . toHaveProperty ( 'src' ) ;
573+ expect ( attrsDiff ?. modified ) . toHaveProperty ( 'size.width' ) ;
574+
575+ // Volatile changes are NOT reported
576+ expect ( attrsDiff ?. modified ) . not . toHaveProperty ( 'originalAttributes.wp14:anchorId' ) ;
577+ expect ( attrsDiff ?. modified ) . not . toHaveProperty ( 'originalAttributes.wp14:editId' ) ;
578+ } ) ;
579+ } ) ;
0 commit comments