1- /**
1+ /*
22 * reportExport.test.ts
3- * Issue #627 – unit coverage for the dependency-free report generators.
3+ * Issue #627 - unit coverage for the dependency-free report generators.
44 */
55
66import { describe , expect , it } from "vitest" ;
@@ -81,7 +81,7 @@ describe("toCsv", () => {
8181 } ) ;
8282
8383 it ( "quotes cells containing commas, quotes, or newlines" , ( ) => {
84- const csv = toCsv ( [ [ ' a,b' , 'say "hi"' , "line1\nline2" ] ] ) ;
84+ const csv = toCsv ( [ [ " a,b" , 'say "hi"' , "line1\nline2" ] ] ) ;
8585 expect ( csv ) . toBe ( '"a,b","say ""hi""","line1\nline2"\r\n' ) ;
8686 } ) ;
8787} ) ;
@@ -105,6 +105,60 @@ describe("toXlsxBytes", () => {
105105 } ) ;
106106} ) ;
107107
108+ /**
109+ * Parse the xref table and verify that every offset points at the
110+ * corresponding "N 0 obj" header, and that every /Length matches the
111+ * actual byte length of the stream between "stream\n" and "\nendstream".
112+ */
113+ function assertPdfByteAccuracy ( bytes : Uint8Array ) : void {
114+ const decoder = new TextDecoder ( "latin1" ) ;
115+ const text = decoder . decode ( bytes ) ;
116+
117+ // Locate the xref table via startxref.
118+ const startxrefMatch = / s t a r t x r e f \n ( \d + ) \n % % E O F / . exec ( text ) ;
119+ expect ( startxrefMatch ) . not . toBeNull ( ) ;
120+ const startxref = Number ( startxrefMatch ! [ 1 ] ) ;
121+ expect ( Number . isFinite ( startxref ) ) . toBe ( true ) ;
122+ expect ( text . slice ( startxref , startxref + 4 ) ) . toBe ( "xref" ) ;
123+
124+ // Parse the xref table entries: "xref\n0 N\n0000000000 65535 f\n0000000010 00000 n\n..."
125+ const xrefBlock = text . slice ( startxref ) ;
126+ const lines = xrefBlock . split ( "\n" ) ;
127+ expect ( lines [ 0 ] ) . toBe ( "xref" ) ;
128+ // lines[1] = "0 N" (subsection header)
129+ const headerParts = lines [ 1 ] . split ( " " ) ;
130+ const count = Number ( headerParts [ 1 ] ) ;
131+ expect ( Number . isFinite ( count ) ) . toBe ( true ) ;
132+
133+ // Entry 0 is the free list head; entries 1..count-1 are objects.
134+ for ( let i = 1 ; i < count ; i ++ ) {
135+ const entry = lines [ 1 + i ] ;
136+ const match = / ^ ( \d { , 10 } ) ( \d { 3 , 5 } ) ( [ n f ] ) $ / . exec ( entry ) ;
137+ expect ( match , `xref entry ${ i } cannot be parsed: ${ entry } ` ) . not . toBeNull ( ) ;
138+ const offset = Number ( match ! [ 1 ] ) ;
139+ const header = `${ i } 0 obj` ;
140+ expect (
141+ text . slice ( offset , offset + header . length ) ,
142+ `xref offset ${ offset } for object ${ i } should point at "${ header } "`
143+ ) . toBe ( header ) ;
144+ }
145+
146+ // Verify /Length of every stream equals the byte length of the stream data.
147+ const streamRe = / \/ L e n g t h ( \d + ) > > \n s t r e a m \n / g;
148+ let match : RegExpExecArray | null ;
149+ let streams = 0 ;
150+ while ( ( match = streamRe . exec ( text ) ) !== null ) {
151+ streams ++ ;
152+ const declaredLength = Number ( match [ 1 ] ) ;
153+ const streamStart = match . index + match [ 0 ] . length ;
154+ const endIdx = text . indexOf ( "\nendstream" , streamStart ) ;
155+ expect ( endIdx , `stream at ${ streamStart } must have an endstream marker` ) . toBeGreaterThan ( - 1 ) ;
156+ const actualLength = endIdx - streamStart ;
157+ expect ( declaredLength , `/Length must equal byte length of stream` ) . toBe ( actualLength ) ;
158+ }
159+ expect ( streams ) . toBeGreaterThan ( 0 ) ;
160+ }
161+
108162describe ( "toPdfBytes" , ( ) => {
109163 it ( "produces a structurally valid single-page PDF" , ( ) => {
110164 const bytes = toPdfBytes ( [
@@ -124,4 +178,60 @@ describe("toPdfBytes", () => {
124178 expect ( Number . isFinite ( startxref ) ) . toBe ( true ) ;
125179 expect ( text . slice ( startxref , startxref + 4 ) ) . toBe ( "xref" ) ;
126180 } ) ;
181+
182+ it ( "emits byte-accurate /Length and xref offsets for ASCII input" , ( ) => {
183+ const bytes = toPdfBytes ( [
184+ [ "Period" , "Gross revenue" ] ,
185+ [ "May 2026" , "125000" ] ,
186+ ] ) ;
187+ assertPdfByteAccuracy ( bytes ) ;
188+ } ) ;
189+
190+ it ( "emits byte-accurate /Length and xref offsets for non-ASCII input" , ( ) => {
191+ // Regression: this failed on master because .length measured UTF-16
192+ // code units while the document was encoded as UTF-8.
193+ const bytes = toPdfBytes ( [
194+ [ "Period" , "Gross revenue" , "Notes" ] ,
195+ [ "May 2026" , "Revenue €1.000" , "café - ₭1.000" ] ,
196+ ] ) ;
197+ assertPdfByteAccuracy ( bytes ) ;
198+ } ) ;
199+
200+ it ( "escapes parentheses and backslashes in text runs" , ( ) => {
201+ const bytes = toPdfBytes ( [
202+ [ "Period" , "Notes" ] ,
203+ [ "May 2026" , "Quarter (Q2) \\ final (100%)" ] ,
204+ ] ) ;
205+ assertPdfByteAccuracy ( bytes ) ;
206+
207+ // The raw content stream must not contain unescaped delimiters inside
208+ // the string literal that would terminate it early.
209+ const text = new TextDecoder ( "latin1" ) . decode ( bytes ) ;
210+ const streamMatch = / s t r e a m \n ( [ \s \S ] * ) \n e n d s t r e a m / . exec ( text ) ;
211+ expect ( streamMatch ) . not . toBeNull ( ) ;
212+ const content = streamMatch ! [ 1 ] ;
213+ // Every literal string must balance and escape internal delimiters.
214+ expect ( content ) . toContain ( "\\(" ) ;
215+ expect ( content ) . toContain ( "\\)" ) ;
216+ expect ( content ) . toContain ( "\\\\" ) ;
217+ } ) ;
218+
219+ it ( "wraps long note lines so text stays within the page" , ( ) => {
220+ const longNote =
221+ "Revenue recognition adjustment for the period including " +
222+ "currency conversion and rounding differences across all regions." ;
223+ const bytes = toPdfBytes ( [
224+ [ "Period" , "Notes" ] ,
225+ [ "May 2026" , longNote ] ,
226+ ] ) ;
227+ assertPdfByteAccuracy ( bytes ) ;
228+
229+ const text = new TextDecoder ( "latin1" ) . decode ( bytes ) ;
230+ const streamMatch = / s t r e a m \n ( [ \s \S ] * ) \n e n d s t r e a m / . exec ( text ) ;
231+ expect ( streamMatch ) . not . toBeNull ( ) ;
232+ const content = streamMatch ! [ 1 ] ;
233+ // Multiple text showing operators indicate the note was wrapped.
234+ const showCount = ( content . match ( / \b T j \b / g) ?? [ ] ) . length ;
235+ expect ( showCount ) . toBeGreaterThan ( 1 ) ;
236+ } ) ;
127237} ) ;
0 commit comments