1+ package io .github .easy4j .pdf .it .contract ;
2+
3+ import io .github .easy4j .pdf .xhtml .convert .ExtractReport ;
4+ import io .github .easy4j .pdf .xhtml .convert .ExtractionException ;
5+ import io .github .easy4j .pdf .xhtml .convert .ExtractorMetrics ;
6+ import io .github .easy4j .pdf .xhtml .convert .PdfStructureExtractor ;
7+ import io .github .easy4j .pdf .xhtml .convert .layout .PdfExtractionProperties ;
8+ import org .junit .jupiter .api .Disabled ;
9+ import org .junit .jupiter .api .DisplayName ;
10+ import org .junit .jupiter .api .Test ;
11+
12+ import java .io .File ;
13+ import java .net .URL ;
14+ import java .nio .file .Paths ;
15+
16+ import static org .assertj .core .api .Assertions .assertThat ;
17+ import static org .assertj .core .api .Assertions .assertThatThrownBy ;
18+
19+ /**
20+ * Contract suite: PDF → Markdown deterministic output for hand-picked fixture PDFs.
21+ *
22+ * <p>Fixtures live under {@code src/test/resources/contracts/} and snapshots under
23+ * {@code src/test/resources/snapshots/}. Snapshots MUST be hand-reviewed; the
24+ * purpose of this suite is to catch <em>unintended</em> drift in extractor output,
25+ * not to silently rubber-stamp it.
26+ *
27+ * <p><b>Status:</b> snapshot-driven cases are {@link Disabled @Disabled} until the
28+ * fixtures (deterministic PDFs) and expected markdown snapshots are committed by
29+ * hand. Non-fixture negative cases run on every {@code mvn verify}.
30+ */
31+ class MarkdownContractIT {
32+
33+ private static final String SNAPSHOTS = "/snapshots" ;
34+ private static final String CONTRACTS = "/contracts" ;
35+
36+ private static File fixture (String name ) {
37+ URL url = MarkdownContractIT .class .getResource (CONTRACTS + "/" + name );
38+ assertThat ((Object ) url ).as ("fixture %s missing — commit it under src/test/resources/contracts/" , name )
39+ .isNotNull ();
40+ return Paths .get (url .getPath ()).toFile ();
41+ }
42+
43+ @ Test
44+ @ Disabled ("enable after single-page-plain.pdf fixture + snapshot are committed" )
45+ @ DisplayName ("contract: single-page plain text" )
46+ void singlePagePlainText () throws Exception {
47+ String md = PdfStructureExtractor .extract (fixture ("single-page-plain.pdf" ))
48+ .toMarkdown ();
49+ assertThat (md ).isEqualTo (readSnapshot ("single-page-plain" ));
50+ }
51+
52+ @ Test
53+ @ Disabled ("enable after multi-page-table.pdf fixture + snapshot are committed" )
54+ @ DisplayName ("contract: multi-page with table" )
55+ void multiPageWithTable () throws Exception {
56+ String md = PdfStructureExtractor .extract (fixture ("multi-page-table.pdf" ))
57+ .toMarkdown ();
58+ assertThat (md ).isEqualTo (readSnapshot ("multi-page-table" ));
59+ }
60+
61+ @ Test
62+ @ Disabled ("enable after tagged-report.pdf fixture + snapshot are committed" )
63+ @ DisplayName ("contract: tagged PDF round-trip" )
64+ void taggedRoundTrip () throws Exception {
65+ String md = PdfStructureExtractor .extract (fixture ("tagged-report.pdf" ))
66+ .toMarkdown ();
67+ assertThat (md ).isEqualTo (readSnapshot ("tagged-report" ));
68+ }
69+
70+ @ Test
71+ @ Disabled ("enable after cjk-mixed.pdf fixture + snapshot are committed" )
72+ @ DisplayName ("contract: cjk font fallback" )
73+ void cjkFallback () throws Exception {
74+ String md = PdfStructureExtractor .extract (fixture ("cjk-mixed.pdf" ))
75+ .toMarkdown ();
76+ // CJK 字形在不同 iText 渲染下可能微变;只断言保留中文段存在
77+ assertThat (md ).contains ("标题" ).contains ("正文" );
78+ }
79+
80+ @ Test
81+ @ Disabled ("enable after single-page-plain.pdf fixture is committed" )
82+ @ DisplayName ("contract: extractWithReport counts success in metrics" )
83+ void extractWithReportPopulatesMetrics () throws Exception {
84+ // INSTANCE 是进程级共享,单测之间可能互有污染;
85+ // 只断言"调用后 successes 计数严格 +1"。
86+ long successBefore = ExtractorMetrics .INSTANCE .snapshot ()
87+ .getOrDefault ("successes" , 0L );
88+
89+ ExtractReport report = PdfStructureExtractor .extractWithReport (
90+ fixture ("single-page-plain.pdf" ),
91+ PdfExtractionProperties .defaults ());
92+
93+ assertThat (report ).isNotNull ();
94+ assertThat (report .document ).isNotNull ();
95+ long successAfter = ExtractorMetrics .INSTANCE .snapshot ()
96+ .getOrDefault ("successes" , 0L );
97+ assertThat (successAfter - successBefore ).isEqualTo (1L );
98+ }
99+
100+ @ Test
101+ @ DisplayName ("contract: NOT_FOUND surfaces as ExtractionException" )
102+ void missingFileSurfacesAsNotFound () {
103+ File missing = new File ("target/does-not-exist.pdf" );
104+ assertThatThrownBy (() -> PdfStructureExtractor .extract (missing ))
105+ .isInstanceOf (ExtractionException .class )
106+ .extracting (e -> ((ExtractionException ) e ).getCode ())
107+ .isEqualTo (ExtractionException .Code .NOT_FOUND );
108+ }
109+
110+ @ Test
111+ @ Disabled ("enable after encrypted.pdf fixture is committed" )
112+ @ DisplayName ("contract: ENCRYPTED PDF is classified" )
113+ void encryptedPdfIsClassified () {
114+ // 由 RobustnessTest 等单测覆盖生成加密 PDF;IT 这里只断言默认 props 行为
115+ // ——遇到 ENCRYPTED 抛 ExtractionException(Code.ENCRYPTED)
116+ File encrypted = fixture ("encrypted.pdf" );
117+ assertThatThrownBy (() -> PdfStructureExtractor .extract (encrypted ))
118+ .isInstanceOf (ExtractionException .class )
119+ .extracting (e -> ((ExtractionException ) e ).getCode ())
120+ .isEqualTo (ExtractionException .Code .ENCRYPTED );
121+ }
122+
123+ private static String readSnapshot (String name ) throws Exception {
124+ URL url = MarkdownContractIT .class .getResource (SNAPSHOTS + "/" + name + ".md" );
125+ assertThat ((Object ) url ).as ("snapshot %s.md missing" , name ).isNotNull ();
126+ return new String (java .nio .file .Files .readAllBytes (Paths .get (url .getPath ())), "UTF-8" );
127+ }
128+ }
0 commit comments