Integrate crabrl parser into taxonomy hydration
This commit is contained in:
286
lib/server/taxonomy/parser-client.test.ts
Normal file
286
lib/server/taxonomy/parser-client.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
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: [],
|
||||
equity: [],
|
||||
comprehensive_income: [],
|
||||
},
|
||||
statement_rows: {
|
||||
income: [],
|
||||
balance: [],
|
||||
cash_flow: [],
|
||||
equity: [],
|
||||
comprehensive_income: [],
|
||||
},
|
||||
surface_rows: {
|
||||
income: [],
|
||||
balance: [],
|
||||
cash_flow: [],
|
||||
equity: [],
|
||||
comprehensive_income: [],
|
||||
},
|
||||
detail_rows: {
|
||||
income: {},
|
||||
balance: {},
|
||||
cash_flow: {},
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user