View Javadoc
1   package com.acumenvelocity.ath.common;
2   
3   import static org.junit.jupiter.api.Assertions.assertEquals;
4   import static org.junit.jupiter.api.Assertions.assertFalse;
5   import static org.junit.jupiter.api.Assertions.assertNotNull;
6   import static org.junit.jupiter.api.Assertions.assertNull;
7   import static org.junit.jupiter.api.Assertions.assertTrue;
8   
9   import java.util.ArrayList;
10  import java.util.List;
11  
12  import org.junit.jupiter.api.DisplayName;
13  import org.junit.jupiter.api.Nested;
14  import org.junit.jupiter.api.Test;
15  
16  import com.acumenvelocity.ath.common.AlignmentData.CombinedAlignmentOutput;
17  import com.acumenvelocity.ath.model.InlineCode;
18  import com.acumenvelocity.ath.model.x.LayeredTextX;
19  import com.fasterxml.jackson.databind.JsonNode;
20  
21  import net.sf.okapi.common.LocaleId;
22  import net.sf.okapi.common.resource.Code;
23  import net.sf.okapi.common.resource.TextFragment;
24  import net.sf.okapi.common.resource.TextFragment.TagType;
25  
26  class TestConversionUtil {
27  
28    @Nested
29    @DisplayName("toTextFragment tests")
30    class ToTextFragmentTests {
31  
32      @Test
33      @DisplayName("should return null when input is null")
34      void testNullInput() {
35        assertNull(ConversionUtil.toTextFragment(null));
36      }
37  
38      @Test
39      @DisplayName("should convert simple text without codes")
40      void testSimpleTextWithoutCodes() {
41        LayeredTextX fs = new LayeredTextX();
42        fs.setText("Hello World");
43        fs.setCodes(new ArrayList<>());
44  
45        TextFragment tf = ConversionUtil.toTextFragment(fs);
46  
47        assertNotNull(tf);
48        assertEquals("Hello World", tf.getText());
49        assertFalse(tf.hasCode());
50      }
51  
52      @Test
53      @DisplayName("should convert text with single placeholder code")
54      void testTextWithPlaceholderCode() {
55        LayeredTextX fs = new LayeredTextX();
56        fs.setText("Hello World");
57  
58        List<InlineCode> codes = new ArrayList<>();
59        InlineCode ic = new InlineCode();
60        ic.setPosition(5);
61        ic.setId(1);
62        ic.setType("image");
63        ic.setTagType(TagType.PLACEHOLDER);
64        ic.setData("<img src='test.png'/>");
65        codes.add(ic);
66  
67        fs.setCodes(codes);
68  
69        TextFragment tf = ConversionUtil.toTextFragment(fs);
70  
71        assertNotNull(tf);
72        assertEquals("Hello World", tf.getText());
73        assertTrue(tf.hasCode());
74        assertEquals(1, tf.getCodes().size());
75  
76        Code code = tf.getCodes().get(0);
77        assertEquals(1, code.getId());
78        assertEquals("image", code.getType());
79        assertEquals("<img src='test.png'/>", code.getData());
80        assertEquals(TagType.PLACEHOLDER, code.getTagType());
81      }
82  
83      @Test
84      @DisplayName("should convert text with opening and closing codes")
85      void testTextWithOpeningClosingCodes() {
86        LayeredTextX fs = new LayeredTextX();
87        fs.setText("Hello World");
88  
89        List<InlineCode> codes = new ArrayList<>();
90  
91        InlineCode opening = new InlineCode();
92        opening.setPosition(0);
93        opening.setId(1);
94        opening.setType("bold");
95        opening.setTagType(TagType.OPENING);
96        opening.setData("<b>");
97        codes.add(opening);
98  
99        InlineCode closing = new InlineCode();
100       closing.setPosition(5);
101       closing.setId(1);
102       closing.setType("bold");
103       closing.setTagType(TagType.CLOSING);
104       closing.setData("</b>");
105       codes.add(closing);
106 
107       fs.setCodes(codes);
108 
109       TextFragment tf = ConversionUtil.toTextFragment(fs);
110 
111       assertNotNull(tf);
112       assertEquals("Hello World", tf.getText());
113       assertTrue(tf.hasCode());
114       assertEquals(2, tf.getCodes().size());
115 
116       assertEquals(TagType.OPENING, tf.getCodes().get(0).getTagType());
117       assertEquals(TagType.CLOSING, tf.getCodes().get(1).getTagType());
118       assertEquals(1, tf.getCodes().get(0).getId());
119       assertEquals(1, tf.getCodes().get(1).getId());
120     }
121 
122     @Test
123     @DisplayName("round-trip should preserve paired opening/closing codes at end of text (position 2)")
124     void testRoundTripPairedCodesAtEndOfText() {
125       // === Step 1: Create original LayeredTextX ===
126       LayeredTextX original = new LayeredTextX();
127       original.setText("5.");
128 
129       List<InlineCode> codes = new ArrayList<>();
130 
131       InlineCode opening = new InlineCode();
132       opening.setPosition(2);
133       opening.setId(1);
134       opening.setType("run1");
135       opening.setTagType(TagType.OPENING);
136       opening.setData("<run1>");
137       codes.add(opening);
138 
139       InlineCode closing = new InlineCode();
140       closing.setPosition(2);
141       closing.setId(1);
142       closing.setType("run1");
143       closing.setTagType(TagType.CLOSING);
144       closing.setData("</run1>");
145       codes.add(closing);
146 
147       original.setCodes(codes);
148 
149       // === Step 2: Convert to TextFragment ===
150       TextFragment tf = ConversionUtil.toTextFragment(original);
151 
152       assertNotNull(tf);
153       assertEquals("5.", tf.getText());
154       assertEquals("5.<run1></run1>", tf.toText());
155       assertEquals(2, tf.getCodes().size());
156 
157       // Verify code markers are placed at the end (after "5.")
158       assertEquals(2, tf.getCodePosition(0)); // opening marker starts at index 2
159       assertEquals(4, tf.getCodePosition(1)); // closing marker starts at index 4
160 
161       // === Step 3: Convert back to LayeredTextX ===
162       LayeredTextX result = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
163 
164       // === Step 4: Validate round-trip integrity ===
165       assertNotNull(result);
166       assertEquals(original.getText(), result.getText());
167       assertEquals("5.<run1></run1>", result.getTextWithCodes());
168       assertEquals(2, result.getCodes().size());
169 
170       // Validate both codes have position 2
171       assertEquals(2, result.getCodes().get(0).getPosition());
172       assertEquals(2, result.getCodes().get(1).getPosition());
173 
174       // Validate opening code
175       InlineCode resultOpening = result.getCodes().get(0);
176       assertEquals(1, resultOpening.getId());
177       assertEquals("run1", resultOpening.getType());
178       assertEquals("<run1>", resultOpening.getData());
179       assertEquals(TagType.OPENING, resultOpening.getTagType());
180 
181       // Validate closing code
182       InlineCode resultClosing = result.getCodes().get(1);
183       assertEquals(1, resultClosing.getId());
184       assertEquals("run1", resultClosing.getType());
185       assertEquals("</run1>", resultClosing.getData());
186       assertEquals(TagType.CLOSING, resultClosing.getTagType());
187     }
188 
189     @Test
190     @DisplayName("should handle codes in non-sequential position order")
191     void testCodesOutOfOrder() {
192       LayeredTextX fs = new LayeredTextX();
193       fs.setText("Hello World");
194 
195       List<InlineCode> codes = new ArrayList<>();
196 
197       // Add closing code first
198       InlineCode closing = new InlineCode();
199       closing.setPosition(11);
200       closing.setId(1);
201       closing.setType("bold");
202       closing.setTagType(TagType.CLOSING);
203       closing.setData("</b>");
204       codes.add(closing);
205 
206       // Add opening code second
207       InlineCode opening = new InlineCode();
208       opening.setPosition(0);
209       opening.setId(1);
210       opening.setType("bold");
211       opening.setTagType(TagType.OPENING);
212       opening.setData("<b>");
213       codes.add(opening);
214 
215       fs.setCodes(codes);
216 
217       TextFragment tf = ConversionUtil.toTextFragment(fs);
218 
219       assertNotNull(tf);
220       assertEquals("Hello World", tf.getText());
221       assertTrue(tf.hasCode());
222       assertEquals(2, tf.getCodes().size());
223     }
224 
225     @Test
226     @DisplayName("should convert flags correctly")
227     void testFlagConversion() {
228       LayeredTextX fs = new LayeredTextX();
229       fs.setText("Test");
230 
231       List<InlineCode> codes = new ArrayList<>();
232       InlineCode ic = new InlineCode();
233       ic.setPosition(0);
234       ic.setId(1);
235       ic.setType("test");
236       ic.setTagType(TagType.PLACEHOLDER);
237       ic.setData("<test/>");
238       ic.setFlag(0x01 | 0x02 | 0x04); // HASREF | CLONEABLE | DELETEABLE
239       codes.add(ic);
240 
241       fs.setCodes(codes);
242 
243       TextFragment tf = ConversionUtil.toTextFragment(fs);
244 
245       Code code = tf.getCodes().get(0);
246       assertTrue(code.hasReference());
247       assertTrue(code.isCloneable());
248       assertTrue(code.isDeleteable());
249     }
250 
251     @Test
252     @DisplayName("should preserve additional properties")
253     void testAdditionalProperties() {
254       LayeredTextX fs = new LayeredTextX();
255       fs.setText("Test");
256 
257       List<InlineCode> codes = new ArrayList<>();
258       InlineCode ic = new InlineCode();
259       ic.setPosition(0);
260       ic.setId(5);
261       ic.setType("link");
262       ic.setTagType(TagType.PLACEHOLDER);
263       ic.setData("<a href='test'>");
264       ic.setOuterData("&lt;a href='test'&gt;");
265       ic.setDisplayText("Link");
266       ic.setOriginalId("abc123");
267       codes.add(ic);
268 
269       fs.setCodes(codes);
270 
271       TextFragment tf = ConversionUtil.toTextFragment(fs);
272 
273       Code code = tf.getCodes().get(0);
274       assertEquals(5, code.getId());
275       assertEquals("&lt;a href='test'&gt;", code.getOuterData());
276       assertEquals("Link", code.getDisplayText());
277       assertEquals("abc123", code.getOriginalId());
278     }
279 
280     @Test
281     @DisplayName("should handle empty text")
282     void testEmptyText() {
283       LayeredTextX fs = new LayeredTextX();
284       fs.setText("");
285       fs.setCodes(new ArrayList<>());
286 
287       TextFragment tf = ConversionUtil.toTextFragment(fs);
288 
289       assertNotNull(tf);
290       assertEquals("", tf.getText());
291     }
292 
293     @Test
294     @DisplayName("should handle null text")
295     void testNullText() {
296       LayeredTextX fs = new LayeredTextX();
297       fs.setText(null);
298       fs.setCodes(new ArrayList<>());
299 
300       TextFragment tf = ConversionUtil.toTextFragment(fs);
301 
302       assertNotNull(tf);
303       assertEquals("", tf.getText());
304     }
305   }
306 
307   @Nested
308   @DisplayName("toLayeredText tests")
309   class ToLayeredTextTests {
310 
311     @Test
312     @DisplayName("should return null when input is null")
313     void testNullInput() {
314       assertNull(ConversionUtil.toLayeredText(null, LocaleId.ENGLISH));
315     }
316 
317     @Test
318     @DisplayName("should convert simple text without codes")
319     void testSimpleTextWithoutCodes() {
320       TextFragment tf = new TextFragment("Hello World");
321 
322       LayeredTextX fs = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
323 
324       assertNotNull(fs);
325       assertEquals("Hello World", fs.getText());
326       assertEquals("Hello World", fs.getTextWithCodes());
327       assertTrue(fs.getCodes().isEmpty());
328     }
329 
330     @Test
331     @DisplayName("should convert text with single placeholder code")
332     void testTextWithPlaceholderCode() {
333       TextFragment tf = new TextFragment("Hello ");
334       tf.append(TagType.PLACEHOLDER, "image", "<img src='test.png'/>");
335       tf.append(" World");
336 
337       LayeredTextX fs = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
338 
339       assertNotNull(fs);
340       assertEquals("Hello  World", fs.getText());
341       assertEquals(1, fs.getCodes().size());
342 
343       InlineCode ic = fs.getCodes().get(0);
344       assertEquals(6, ic.getPosition());
345       assertEquals("image", ic.getType());
346       assertEquals("<img src='test.png'/>", ic.getData());
347       assertEquals(TagType.PLACEHOLDER, ic.getTagType());
348     }
349 
350     @Test
351     @DisplayName("should convert text with opening and closing codes")
352     void testTextWithOpeningClosingCodes() {
353       TextFragment tf = new TextFragment();
354       tf.append(TagType.OPENING, "bold", "<b>", 1);
355       tf.append("Hello");
356       tf.append(TagType.CLOSING, "bold", "</b>", 1);
357       tf.append(" World");
358 
359       LayeredTextX fs = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
360 
361       assertNotNull(fs);
362       assertEquals("Hello World", fs.getText());
363       assertEquals(2, fs.getCodes().size());
364 
365       InlineCode opening = fs.getCodes().get(0);
366       assertEquals(0, opening.getPosition());
367       assertEquals(1, opening.getId());
368       assertEquals("bold", opening.getType());
369       assertEquals(TagType.OPENING, opening.getTagType());
370 
371       InlineCode closing = fs.getCodes().get(1);
372       assertEquals(5, closing.getPosition());
373       assertEquals(1, closing.getId());
374       assertEquals("bold", closing.getType());
375       assertEquals(TagType.CLOSING, closing.getTagType());
376     }
377 
378     @Test
379     @DisplayName("should calculate positions correctly with multiple codes")
380     void testPositionCalculation() {
381       TextFragment tf = new TextFragment();
382       tf.append("AB");
383       tf.append(TagType.PLACEHOLDER, "code1", "<c1/>", 1);
384       tf.append("CD");
385       tf.append(TagType.PLACEHOLDER, "code2", "<c2/>", 2);
386       tf.append("EF");
387 
388       LayeredTextX fs = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
389 
390       assertEquals("ABCDEF", fs.getText());
391       assertEquals(2, fs.getCodes().size());
392 
393       assertEquals(2, fs.getCodes().get(0).getPosition());
394       assertEquals(4, fs.getCodes().get(1).getPosition());
395     }
396 
397     @Test
398     @DisplayName("should convert flags correctly")
399     void testFlagConversion() {
400       TextFragment tf = new TextFragment();
401       Code code = tf.append(TagType.PLACEHOLDER, "test", "<test/>");
402       code.setReferenceFlag(true);
403       code.setCloneable(true);
404       code.setDeleteable(true);
405 
406       LayeredTextX fs = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
407 
408       InlineCode ic = fs.getCodes().get(0);
409       assertEquals(0x07, ic.getFlag()); // 0x01 | 0x02 | 0x04
410       assertTrue((ic.getFlag() & 0x01) != 0); // HASREF
411       assertTrue((ic.getFlag() & 0x02) != 0); // CLONEABLE
412       assertTrue((ic.getFlag() & 0x04) != 0); // DELETEABLE
413     }
414 
415     @Test
416     @DisplayName("should preserve additional properties")
417     void testAdditionalProperties() {
418       TextFragment tf = new TextFragment();
419       Code code = tf.append(TagType.PLACEHOLDER, "link", "<a href='test'>", 5);
420       code.setOuterData("&lt;a href='test'&gt;");
421       code.setDisplayText("Link");
422       code.setOriginalId("abc123");
423 
424       LayeredTextX fs = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
425 
426       InlineCode ic = fs.getCodes().get(0);
427       assertEquals(5, ic.getId());
428       assertEquals("&lt;a href='test'&gt;", ic.getOuterData());
429       assertEquals("Link", ic.getDisplayText());
430       assertEquals("abc123", ic.getOriginalId());
431     }
432 
433     @Test
434     @DisplayName("should handle empty text")
435     void testEmptyText() {
436       TextFragment tf = new TextFragment("");
437 
438       LayeredTextX fs = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
439 
440       assertNotNull(fs);
441       assertEquals("", fs.getText());
442       assertTrue(fs.getCodes().isEmpty());
443     }
444 
445     @Test
446     @DisplayName("should set textWithCodes correctly")
447     void testTextWithCodes() {
448       TextFragment tf = new TextFragment();
449       tf.append(TagType.OPENING, "bold", "<b>", 1);
450       tf.append("Hello");
451       tf.append(TagType.CLOSING, "bold", "</b>", 1);
452 
453       LayeredTextX fs = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
454 
455       assertEquals("<b>Hello</b>", fs.getTextWithCodes());
456     }
457   }
458 
459   @Nested
460   @DisplayName("Round-trip conversion tests")
461   class RoundTripTests {
462 
463     @Test
464     @DisplayName("should maintain data integrity through round-trip conversion")
465     void testRoundTripSimple() {
466       LayeredTextX original = new LayeredTextX();
467       original.setText("Hello World");
468 
469       List<InlineCode> codes = new ArrayList<>();
470       InlineCode ic = new InlineCode();
471       ic.setPosition(6);
472       ic.setId(1);
473       ic.setType("emphasis");
474       ic.setTagType(TagType.PLACEHOLDER);
475       ic.setData("<em/>");
476       codes.add(ic);
477 
478       original.setCodes(codes);
479 
480       TextFragment tf = ConversionUtil.toTextFragment(original);
481       LayeredTextX result = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
482 
483       assertEquals(original.getText(), result.getText());
484       assertEquals(original.getCodes().size(), result.getCodes().size());
485 
486       InlineCode resultCode = result.getCodes().get(0);
487       InlineCode originalCode = original.getCodes().get(0);
488 
489       assertEquals(originalCode.getPosition(), resultCode.getPosition());
490       assertEquals(originalCode.getType(), resultCode.getType());
491       assertEquals(originalCode.getData(), resultCode.getData());
492       assertEquals(originalCode.getTagType(), resultCode.getTagType());
493     }
494 
495     @Test
496     @DisplayName("should maintain paired codes through round-trip")
497     void testRoundTripPairedCodes() {
498       LayeredTextX original = new LayeredTextX();
499       original.setText("Bold text here");
500 
501       List<InlineCode> codes = new ArrayList<>();
502 
503       InlineCode opening = new InlineCode();
504       opening.setPosition(0);
505       opening.setId(1);
506       opening.setType("bold");
507       opening.setTagType(TagType.OPENING);
508       opening.setData("<b>");
509       codes.add(opening);
510 
511       InlineCode closing = new InlineCode();
512       closing.setPosition(4);
513       closing.setId(1);
514       closing.setType("bold");
515       closing.setTagType(TagType.CLOSING);
516       closing.setData("</b>");
517       codes.add(closing);
518 
519       original.setCodes(codes);
520 
521       TextFragment tf = ConversionUtil.toTextFragment(original);
522       LayeredTextX result = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
523 
524       assertEquals(original.getText(), result.getText());
525       assertEquals(2, result.getCodes().size());
526 
527       assertEquals(TagType.OPENING, result.getCodes().get(0).getTagType());
528       assertEquals(TagType.CLOSING, result.getCodes().get(1).getTagType());
529       assertEquals(result.getCodes().get(0).getId(), result.getCodes().get(1).getId());
530     }
531 
532     @Test
533     @DisplayName("should preserve all properties through round-trip")
534     void testRoundTripAllProperties() {
535       LayeredTextX original = new LayeredTextX();
536       original.setText("Test");
537 
538       List<InlineCode> codes = new ArrayList<>();
539       InlineCode ic = new InlineCode();
540       ic.setPosition(0);
541       ic.setId(5);
542       ic.setType("link");
543       ic.setTagType(TagType.PLACEHOLDER);
544       ic.setData("<a>");
545       ic.setOuterData("&lt;a&gt;");
546       ic.setDisplayText("Link");
547       ic.setOriginalId("link1");
548       ic.setFlag(0x07);
549       codes.add(ic);
550 
551       original.setCodes(codes);
552 
553       TextFragment tf = ConversionUtil.toTextFragment(original);
554       LayeredTextX result = ConversionUtil.toLayeredText(tf, LocaleId.ENGLISH);
555 
556       InlineCode resultCode = result.getCodes().get(0);
557 
558       assertEquals(ic.getId(), resultCode.getId());
559       assertEquals(ic.getType(), resultCode.getType());
560       assertEquals(ic.getData(), resultCode.getData());
561       assertEquals(ic.getOuterData(), resultCode.getOuterData());
562       assertEquals(ic.getDisplayText(), resultCode.getDisplayText());
563       assertEquals(ic.getOriginalId(), resultCode.getOriginalId());
564       assertEquals(ic.getFlag(), resultCode.getFlag());
565     }
566   }
567 
568   // ===================================================================
569   // NEW: JacksonUtil.fromJson test for CombinedAlignmentOutput (Java 11)
570   // ===================================================================
571 
572   @Nested
573   @DisplayName("JacksonUtil.fromJson tests")
574   class JacksonUtilTests {
575 
576     private static final String COMBINED_ALIGNMENT_JSON = "{\n" +
577         "  \"alignments\": [\n" +
578         "    {\n" +
579         "      \"paragraphAlignment\": {\n" +
580         "        \"type\": \"MULTI_MATCH\",\n" +
581         "        \"sourceParagraphPositions\": [ 0 ],\n" +
582         "        \"targetParagraphPositions\": [ 0, 1 ]\n" +
583         "      },\n" +
584         "      \"sentenceAlignments\": [\n" +
585         "        {\n" +
586         "          \"type\": \"MULTI_MATCH\",\n" +
587         "          \"sourcePositions\": [ 0 ],\n" +
588         "          \"targetPositions\": [ 0, 1 ]\n" +
589         "        }\n" +
590         "      ]\n" +
591         "    },\n" +
592         "    {\n" +
593         "      \"paragraphAlignment\": {\n" +
594         "        \"type\": \"MULTI_MATCH\",\n" +
595         "        \"sourceParagraphPositions\": [ 1 ],\n" +
596         "        \"targetParagraphPositions\": [ 2, 3 ]\n" +
597         "      },\n" +
598         "      \"sentenceAlignments\": [\n" +
599         "        {\n" +
600         "          \"type\": \"MULTI_MATCH\",\n" +
601         "          \"sourcePositions\": [ 0 ],\n" +
602         "          \"targetPositions\": [ 0, 1 ]\n" +
603         "        }\n" +
604         "      ]\n" +
605         "    },\n" +
606         "    {\n" +
607         "      \"paragraphAlignment\": {\n" +
608         "        \"type\": \"MULTI_MATCH\",\n" +
609         "        \"sourceParagraphPositions\": [ 2 ],\n" +
610         "        \"targetParagraphPositions\": [ 4, 5, 6, 7 ]\n" +
611         "      },\n" +
612         "      \"sentenceAlignments\": [\n" +
613         "        {\n" +
614         "          \"type\": \"MULTI_MATCH\",\n" +
615         "          \"sourcePositions\": [ 0 ],\n" +
616         "          \"targetPositions\": [ 0, 1, 2, 3 ]\n" +
617         "        }\n" +
618         "      ]\n" +
619         "    },\n" +
620         "    {\n" +
621         "      \"paragraphAlignment\": {\n" +
622         "        \"type\": \"MATCH\",\n" +
623         "        \"sourceParagraphPositions\": [ 3 ],\n" +
624         "        \"targetParagraphPositions\": [ 8 ]\n" +
625         "      },\n" +
626         "      \"sentenceAlignments\": [\n" +
627         "        {\n" +
628         "          \"type\": \"MATCH\",\n" +
629         "          \"sourcePositions\": [ 0 ],\n" +
630         "          \"targetPositions\": [ 0 ]\n" +
631         "        }\n" +
632         "      ]\n" +
633         "    }\n" +
634         "  ]\n" +
635         "}";
636 
637     @Test
638     @DisplayName("JacksonUtil.fromJson should correctly deserialize CombinedAlignmentOutput and support round-trip")
639     void testFromJson_CombinedAlignmentOutput() {
640       // --------------------------------------------------------------
641       // 1. Deserialize + immediate round-trip serialization
642       // --------------------------------------------------------------
643       CombinedAlignmentOutput output = JacksonUtil.fromJson(
644           COMBINED_ALIGNMENT_JSON,
645           CombinedAlignmentOutput.class);
646 
647       assertNotNull(output, "Deserialized object must not be null");
648 
649       // Serialize back to compact JSON (no pretty-print)
650       String roundTrippedJson = JacksonUtil.toJson(output, false);
651 
652       // --------------------------------------------------------------
653       // 2. Structural equality check (ignores formatting)
654       // --------------------------------------------------------------
655       JsonNode originalNode = JacksonUtil.makeNode(COMBINED_ALIGNMENT_JSON);
656       JsonNode roundTrippedNode = JacksonUtil.makeNode(roundTrippedJson);
657 
658       assertNotNull(originalNode, "Original JSON must be parseable");
659       assertNotNull(roundTrippedNode, "Round-tripped JSON must be parseable");
660 
661       assertEquals(
662           originalNode.toString(),
663           roundTrippedNode.toString(),
664           "Round-tripped JSON must be identical (structure + values) to the original");
665 
666       // --------------------------------------------------------------
667       // 3. Field-level validation
668       // --------------------------------------------------------------
669       assertEquals(4, output.alignments.size(), "Should have 4 alignments");
670 
671       // First alignment
672       var a1 = output.alignments.get(0);
673       assertEquals("MULTI_MATCH", a1.paragraphAlignment.type);
674       assertEquals(List.of(0), a1.paragraphAlignment.sourceParagraphPositions);
675       assertEquals(List.of(0, 1), a1.paragraphAlignment.targetParagraphPositions);
676       var s1 = a1.sentenceAlignments.get(0);
677       assertEquals("MULTI_MATCH", s1.type);
678       assertEquals(List.of(0), s1.sourcePositions);
679       assertEquals(List.of(0, 1), s1.targetPositions);
680 
681       // Second alignment
682       var a2 = output.alignments.get(1);
683       assertEquals("MULTI_MATCH", a2.paragraphAlignment.type);
684       assertEquals(List.of(1), a2.paragraphAlignment.sourceParagraphPositions);
685       assertEquals(List.of(2, 3), a2.paragraphAlignment.targetParagraphPositions);
686 
687       // Third alignment
688       var a3 = output.alignments.get(2);
689       assertEquals("MULTI_MATCH", a3.paragraphAlignment.type);
690       assertEquals(List.of(2), a3.paragraphAlignment.sourceParagraphPositions);
691       assertEquals(List.of(4, 5, 6, 7), a3.paragraphAlignment.targetParagraphPositions);
692       var s3 = a3.sentenceAlignments.get(0);
693       assertEquals(List.of(0, 1, 2, 3), s3.targetPositions);
694 
695       // Fourth alignment
696       var a4 = output.alignments.get(3);
697       assertEquals("MATCH", a4.paragraphAlignment.type);
698       assertEquals(List.of(3), a4.paragraphAlignment.sourceParagraphPositions);
699       assertEquals(List.of(8), a4.paragraphAlignment.targetParagraphPositions);
700       var s4 = a4.sentenceAlignments.get(0);
701       assertEquals("MATCH", s4.type);
702       assertEquals(List.of(0), s4.sourcePositions);
703       assertEquals(List.of(0), s4.targetPositions);
704     }
705 
706     @Test
707     @DisplayName("JacksonUtil.fromJson should return null on malformed JSON")
708     void testFromJson_MalformedJson_ReturnsNull() {
709       String malformed = "{ invalid: json }";
710       CombinedAlignmentOutput result = JacksonUtil.fromJson(malformed,
711           CombinedAlignmentOutput.class);
712       assertNull(result, "Should return null on invalid JSON");
713     }
714   }
715 }