@@ -3,8 +3,14 @@ import { dirname, join } from 'node:path'
33import { fileURLToPath } from 'node:url'
44import { describe , expect , it } from 'vitest'
55import type { SecQuery } from '../src/adapters/sec'
6- import { fetchSecReportShell , fetchSecSection , parseStructureDefinition } from '../src/adapters/sec'
7- import type { Statement } from '../src/model'
6+ import {
7+ fetchSecReportShell ,
8+ fetchSecSection ,
9+ mergeSecSections ,
10+ parseStructureDefinition ,
11+ } from '../src/adapters/sec'
12+ import { isExternalFactUrl } from '../src/constants'
13+ import type { NormalizedReport , Statement } from '../src/model'
814import { buildStatements , footCheck } from '../src/project'
915
1016const here = dirname ( fileURLToPath ( import . meta. url ) )
@@ -134,3 +140,133 @@ describe('sec adapter — cover page (text facts)', () => {
134140 expect ( cell ?. textValue ) . toBe ( 'NVIDIA CORP' )
135141 } )
136142} )
143+
144+ describe ( 'isExternalFactUrl' , ( ) => {
145+ it ( 'matches externalized fact URLs (html + txt) and rejects everything else' , ( ) => {
146+ expect (
147+ isExternalFactUrl (
148+ 'https://public.robosystems.ai/2026/0001522767/0001522767-26-000030/fact_ad2ee5cf0f43.html'
149+ )
150+ ) . toBe ( true )
151+ expect ( isExternalFactUrl ( 'https://public.robosystems.ai/x/fact_deadbeef.txt' ) ) . toBe ( true )
152+ // rejects: not a fact_* URL, wrong extension, non-hex hash, non-url, nullish
153+ expect ( isExternalFactUrl ( 'https://api.robosystems.ai/v1/graphs' ) ) . toBe ( false )
154+ expect ( isExternalFactUrl ( 'https://public.robosystems.ai/x/fact_abc.pdf' ) ) . toBe ( false )
155+ expect ( isExternalFactUrl ( 'https://public.robosystems.ai/x/fact_xyz.html' ) ) . toBe ( false )
156+ expect ( isExternalFactUrl ( '10-K' ) ) . toBe ( false )
157+ expect ( isExternalFactUrl ( null ) ) . toBe ( false )
158+ expect ( isExternalFactUrl ( undefined ) ) . toBe ( false )
159+ } )
160+ } )
161+
162+ describe ( 'projection — multiple structures sharing a block type' , ( ) => {
163+ // A filing has several structures of one canonical type (e.g. the balance
164+ // sheet and its parenthetical). Each Information Block must resolve to its own
165+ // structure via `structureId`, not collapse onto the first blockType match.
166+ const report : NormalizedReport = {
167+ reportId : 't' ,
168+ reportIri : null ,
169+ entity : null ,
170+ informationBlocks : [
171+ { id : 'ib1' , blockType : 'balance_sheet' , factSet : 'fs1' , label : 'A' , structureId : 's1' } ,
172+ { id : 'ib2' , blockType : 'balance_sheet' , factSet : 'fs2' , label : 'B' , structureId : 's2' } ,
173+ ] ,
174+ structures : [
175+ {
176+ id : 's1' ,
177+ blockType : 'balance_sheet' ,
178+ roleUri : null ,
179+ structureName : 'Balance Sheet A' ,
180+ order : 0 ,
181+ } ,
182+ {
183+ id : 's2' ,
184+ blockType : 'balance_sheet' ,
185+ roleUri : null ,
186+ structureName : 'Balance Sheet B' ,
187+ order : 1 ,
188+ } ,
189+ ] ,
190+ facts : [
191+ {
192+ id : 'f1' ,
193+ element : 'x:Cash' ,
194+ period : 'p1' ,
195+ unit : null ,
196+ entity : null ,
197+ factSet : 'fs1' ,
198+ value : 100 ,
199+ decimals : null ,
200+ } ,
201+ {
202+ id : 'f2' ,
203+ element : 'x:Debt' ,
204+ period : 'p1' ,
205+ unit : null ,
206+ entity : null ,
207+ factSet : 'fs2' ,
208+ value : 200 ,
209+ decimals : null ,
210+ } ,
211+ ] ,
212+ elements : {
213+ 'x:Cash' : {
214+ id : 'x:Cash' ,
215+ qname : 'x:Cash' ,
216+ label : 'Cash' ,
217+ balance : 'debit' ,
218+ periodType : 'instant' ,
219+ abstract : false ,
220+ monetary : true ,
221+ } ,
222+ 'x:Debt' : {
223+ id : 'x:Debt' ,
224+ qname : 'x:Debt' ,
225+ label : 'Debt' ,
226+ balance : 'credit' ,
227+ periodType : 'instant' ,
228+ abstract : false ,
229+ monetary : true ,
230+ } ,
231+ } ,
232+ periods : {
233+ p1 : {
234+ id : 'p1' ,
235+ type : 'instant' ,
236+ instant : '2026-01-01' ,
237+ startDate : null ,
238+ endDate : null ,
239+ end : '2026-01-01' ,
240+ } ,
241+ } ,
242+ units : { } ,
243+ calcAssociations : [ ] ,
244+ presAssociations : [ ] ,
245+ }
246+
247+ it ( 'resolves each block to its own structure (title + facts), not the first match' , ( ) => {
248+ const statements = buildStatements ( report )
249+ expect ( statements ) . toHaveLength ( 2 )
250+ expect ( statements . map ( ( s ) => s . title ) ) . toEqual ( [ 'Balance Sheet A' , 'Balance Sheet B' ] )
251+ expect ( statements [ 0 ] . rows . map ( ( r ) => r . element . qname ) ) . toEqual ( [ 'x:Cash' ] )
252+ expect ( statements [ 1 ] . rows . map ( ( r ) => r . element . qname ) ) . toEqual ( [ 'x:Debt' ] )
253+ } )
254+ } )
255+
256+ describe ( 'mergeSecSections — whole-report reconstruction' , ( ) => {
257+ it ( 'concatenates single-section reports into one model' , async ( ) => {
258+ const bsSection = shell . sections . find (
259+ ( s ) => s . canonicalType === 'balance_sheet' && ! / p a r e n t h e t i c a l / i. test ( s . title )
260+ ) !
261+ const coverSection = shell . sections . find ( ( s ) => s . kind === 'Cover' ) !
262+ const bs = await fetchSecSection ( stubQuery , shell , bsSection )
263+ const cover = await fetchSecSection ( stubQuery , shell , coverSection )
264+
265+ const merged = mergeSecSections ( shell , [ bs , cover ] )
266+ expect ( merged . informationBlocks ) . toHaveLength ( 2 )
267+ expect ( merged . structures ) . toHaveLength ( 2 )
268+ expect ( merged . facts . length ) . toBe ( bs . facts . length + cover . facts . length )
269+ expect ( merged . entity ?. name ) . toBe ( 'NVIDIA CORP' )
270+ expect ( buildStatements ( merged ) ) . toHaveLength ( 2 )
271+ } )
272+ } )
0 commit comments