Skip to content

Commit 1f831cf

Browse files
committed
test(sec): section slicing, external-fact detection, and structure disambiguation
- reportSections + sliceReportSection over the holon (the File-mode path) - isExternalFactUrl match/reject cases (html/txt vs. other URLs, non-hex, nullish) - multiple structures sharing a block type each resolve to their own structure - mergeSecSections whole-report reconstruction
1 parent a2c5844 commit 1f831cf

2 files changed

Lines changed: 174 additions & 3 deletions

File tree

test/project.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { fileURLToPath } from 'node:url'
44
import { describe, expect, it } from 'vitest'
55
import { parseJsonld } from '../src/adapters/jsonld'
66
import type { Statement } from '../src/model'
7-
import { buildStatements, calcSubtotals, footCheck } from '../src/project'
7+
import {
8+
buildStatements,
9+
calcSubtotals,
10+
footCheck,
11+
reportSections,
12+
sliceReportSection,
13+
} from '../src/project'
814

915
const here = dirname(fileURLToPath(import.meta.url))
1016
const holon = readFileSync(join(here, 'fixtures', 'seattle-method-case-1.holon.jsonld'), 'utf8')
@@ -99,6 +105,35 @@ describe('projection — income statement values', () => {
99105
})
100106
})
101107

108+
describe('sections — reportSections + sliceReportSection', () => {
109+
it('lists the report sections in canonical order', () => {
110+
expect(reportSections(report).map((s) => s.title)).toEqual([
111+
'Balance Sheet',
112+
'Income Statement',
113+
'Cash Flow Statement',
114+
'Statement of Changes in Equity',
115+
])
116+
})
117+
118+
it('slices to a single section whose buildStatements yields just that statement', () => {
119+
for (const section of reportSections(report)) {
120+
const statements = buildStatements(sliceReportSection(report, section.id))
121+
expect(statements).toHaveLength(1)
122+
expect(statements[0].title).toBe(section.title)
123+
}
124+
})
125+
126+
it('the sliced balance sheet still foots (14,450)', () => {
127+
const bsId = reportSections(report).find((s) => s.title === 'Balance Sheet')!.id
128+
const bs = buildStatements(sliceReportSection(report, bsId))[0]
129+
expect(valueOf(bs, 'rs-gaap:Assets')).toBe(14450)
130+
})
131+
132+
it('an unknown section id slices to an empty report', () => {
133+
expect(sliceReportSection(report, 'nope').informationBlocks).toHaveLength(0)
134+
})
135+
})
136+
102137
describe('footing — live foots-check', () => {
103138
const bs = statementOf('balance_sheet')
104139

test/sec.test.ts

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@ import { dirname, join } from 'node:path'
33
import { fileURLToPath } from 'node:url'
44
import { describe, expect, it } from 'vitest'
55
import 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'
814
import { buildStatements, footCheck } from '../src/project'
915

1016
const 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' && !/parenthetical/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

Comments
 (0)