Create unified disclosure statement to organize footnote disclosures separate from primary financial statements. Disclosures are now grouped by type (tax, debt, securities, derivatives, leases, intangibles, ma, revenue, cash_flow) in a dedicated statement type for cleaner UI presentation.
291 lines
7.4 KiB
TypeScript
291 lines
7.4 KiB
TypeScript
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
|
|
import type {
|
|
TaxonomyHydrationInput,
|
|
TaxonomyHydrationResult,
|
|
} from "@/lib/server/taxonomy/types";
|
|
import { __parserClientInternals } from "@/lib/server/taxonomy/parser-client";
|
|
|
|
function streamFromText(text: string) {
|
|
const encoded = new TextEncoder().encode(text);
|
|
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoded);
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
function sampleHydrationResult(): TaxonomyHydrationResult {
|
|
return {
|
|
filing_id: 1,
|
|
ticker: "AAPL",
|
|
filing_date: "2026-01-30",
|
|
filing_type: "10-Q",
|
|
parse_status: "ready",
|
|
parse_error: null,
|
|
source: "xbrl_instance",
|
|
parser_engine: "fiscal-xbrl",
|
|
parser_version: "0.1.0",
|
|
taxonomy_regime: "us-gaap",
|
|
fiscal_pack: "core",
|
|
periods: [],
|
|
faithful_rows: {
|
|
income: [],
|
|
balance: [],
|
|
cash_flow: [],
|
|
disclosure: [],
|
|
equity: [],
|
|
comprehensive_income: [],
|
|
},
|
|
statement_rows: {
|
|
income: [],
|
|
balance: [],
|
|
cash_flow: [],
|
|
disclosure: [],
|
|
equity: [],
|
|
comprehensive_income: [],
|
|
},
|
|
surface_rows: {
|
|
income: [],
|
|
balance: [],
|
|
cash_flow: [],
|
|
disclosure: [],
|
|
equity: [],
|
|
comprehensive_income: [],
|
|
},
|
|
detail_rows: {
|
|
income: {},
|
|
balance: {},
|
|
cash_flow: {},
|
|
disclosure: {},
|
|
equity: {},
|
|
comprehensive_income: {},
|
|
},
|
|
kpi_rows: [],
|
|
computed_definitions: [],
|
|
contexts: [],
|
|
derived_metrics: null,
|
|
validation_result: null,
|
|
facts_count: 0,
|
|
concepts_count: 0,
|
|
dimensions_count: 0,
|
|
assets: [],
|
|
concepts: [],
|
|
facts: [],
|
|
metric_validations: [],
|
|
normalization_summary: {
|
|
surface_row_count: 0,
|
|
detail_row_count: 0,
|
|
kpi_row_count: 0,
|
|
unmapped_row_count: 0,
|
|
material_unmapped_row_count: 0,
|
|
warnings: [],
|
|
},
|
|
xbrl_validation: {
|
|
status: "passed",
|
|
},
|
|
};
|
|
}
|
|
|
|
function sampleInput(): TaxonomyHydrationInput {
|
|
return {
|
|
filingId: 1,
|
|
ticker: "AAPL",
|
|
cik: "0000320193",
|
|
accessionNumber: "0000320193-26-000001",
|
|
filingDate: "2026-01-30",
|
|
filingType: "10-Q",
|
|
filingUrl:
|
|
"https://www.sec.gov/Archives/edgar/data/320193/000032019326000001/",
|
|
primaryDocument: "a10q.htm",
|
|
};
|
|
}
|
|
|
|
const passThroughTimeout = ((handler: TimerHandler, timeout?: number) =>
|
|
globalThis.setTimeout(
|
|
handler,
|
|
timeout,
|
|
)) as unknown as typeof globalThis.setTimeout;
|
|
const immediateTimeout = ((handler: TimerHandler) => {
|
|
if (typeof handler === "function") {
|
|
handler();
|
|
}
|
|
|
|
return 1 as unknown as ReturnType<typeof globalThis.setTimeout>;
|
|
}) as unknown as typeof globalThis.setTimeout;
|
|
|
|
describe("parser client", () => {
|
|
beforeEach(() => {
|
|
delete process.env.FISCAL_XBRL_BIN;
|
|
delete process.env.XBRL_ENGINE_TIMEOUT_MS;
|
|
});
|
|
|
|
it("throws when the sidecar binary cannot be resolved", () => {
|
|
expect(() =>
|
|
__parserClientInternals.resolveFiscalXbrlBinary({
|
|
existsSync: () => false,
|
|
}),
|
|
).toThrow(/Rust XBRL sidecar binary is required/);
|
|
});
|
|
|
|
it("returns parsed sidecar JSON on success", async () => {
|
|
const stdinWrite = mock(() => {});
|
|
const stdinEnd = mock(() => {});
|
|
|
|
const result = await __parserClientInternals.hydrateFromSidecarImpl(
|
|
sampleInput(),
|
|
{
|
|
existsSync: () => true,
|
|
spawn: mock(() => ({
|
|
stdin: {
|
|
write: stdinWrite,
|
|
end: stdinEnd,
|
|
},
|
|
stdout: streamFromText(JSON.stringify(sampleHydrationResult())),
|
|
stderr: streamFromText(""),
|
|
exited: Promise.resolve(0),
|
|
kill: mock(() => {}),
|
|
})) as never,
|
|
setTimeout: passThroughTimeout,
|
|
clearTimeout,
|
|
},
|
|
);
|
|
|
|
expect(result.parser_engine).toBe("fiscal-xbrl");
|
|
expect(stdinWrite).toHaveBeenCalledTimes(1);
|
|
expect(stdinEnd).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("throws when the sidecar exits non-zero", async () => {
|
|
await expect(
|
|
__parserClientInternals.hydrateFromSidecarImpl(sampleInput(), {
|
|
existsSync: () => true,
|
|
spawn: mock(() => ({
|
|
stdin: {
|
|
write: () => {},
|
|
end: () => {},
|
|
},
|
|
stdout: streamFromText(""),
|
|
stderr: streamFromText("fatal parse error"),
|
|
exited: Promise.resolve(3),
|
|
kill: mock(() => {}),
|
|
})) as never,
|
|
setTimeout: passThroughTimeout,
|
|
clearTimeout,
|
|
}),
|
|
).rejects.toThrow(/exit code 3/);
|
|
});
|
|
|
|
it("throws on invalid JSON stdout", async () => {
|
|
await expect(
|
|
__parserClientInternals.hydrateFromSidecarImpl(sampleInput(), {
|
|
existsSync: () => true,
|
|
spawn: mock(() => ({
|
|
stdin: {
|
|
write: () => {},
|
|
end: () => {},
|
|
},
|
|
stdout: streamFromText("{not json"),
|
|
stderr: streamFromText(""),
|
|
exited: Promise.resolve(0),
|
|
kill: mock(() => {}),
|
|
})) as never,
|
|
setTimeout: passThroughTimeout,
|
|
clearTimeout,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("kills the sidecar when the timeout fires", async () => {
|
|
const kill = mock(() => {});
|
|
|
|
await expect(
|
|
__parserClientInternals.hydrateFromSidecarImpl(sampleInput(), {
|
|
existsSync: () => true,
|
|
spawn: mock(() => ({
|
|
stdin: {
|
|
write: () => {},
|
|
end: () => {},
|
|
},
|
|
stdout: streamFromText(""),
|
|
stderr: streamFromText("killed"),
|
|
exited: Promise.resolve(137),
|
|
kill,
|
|
})) as never,
|
|
setTimeout: immediateTimeout,
|
|
clearTimeout: () => {},
|
|
}),
|
|
).rejects.toThrow(/exit code 137/);
|
|
|
|
expect(kill).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("retries retryable sidecar failures but not invalid requests", async () => {
|
|
let attempts = 0;
|
|
const spawn = mock(() => {
|
|
attempts += 1;
|
|
|
|
const exitCode = attempts < 3 ? 1 : 0;
|
|
const stdout =
|
|
exitCode === 0 ? JSON.stringify(sampleHydrationResult()) : "";
|
|
const stderr = exitCode === 0 ? "" : "process killed";
|
|
|
|
return {
|
|
stdin: {
|
|
write: () => {},
|
|
end: () => {},
|
|
},
|
|
stdout: streamFromText(stdout),
|
|
stderr: streamFromText(stderr),
|
|
exited: Promise.resolve(exitCode),
|
|
kill: mock(() => {}),
|
|
};
|
|
});
|
|
|
|
const result =
|
|
await __parserClientInternals.hydrateFilingTaxonomySnapshotFromSidecarWithDeps(
|
|
sampleInput(),
|
|
{
|
|
existsSync: () => true,
|
|
spawn: spawn as never,
|
|
setTimeout: passThroughTimeout,
|
|
clearTimeout,
|
|
},
|
|
);
|
|
|
|
expect(result.parser_version).toBe("0.1.0");
|
|
expect(attempts).toBe(3);
|
|
|
|
attempts = 0;
|
|
const invalidRequestSpawn = mock(() => {
|
|
attempts += 1;
|
|
|
|
return {
|
|
stdin: {
|
|
write: () => {},
|
|
end: () => {},
|
|
},
|
|
stdout: streamFromText(""),
|
|
stderr: streamFromText("invalid request: bad command"),
|
|
exited: Promise.resolve(6),
|
|
kill: mock(() => {}),
|
|
};
|
|
});
|
|
|
|
await expect(
|
|
__parserClientInternals.hydrateFilingTaxonomySnapshotFromSidecarWithDeps(
|
|
sampleInput(),
|
|
{
|
|
existsSync: () => true,
|
|
spawn: invalidRequestSpawn as never,
|
|
setTimeout: passThroughTimeout,
|
|
clearTimeout,
|
|
},
|
|
),
|
|
).rejects.toThrow(/invalid request/);
|
|
expect(attempts).toBe(1);
|
|
});
|
|
});
|