599 lines
17 KiB
TypeScript
599 lines
17 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import {
|
|
buildStatementTree,
|
|
resolveStatementSelection,
|
|
} from "@/lib/financials/statement-view-model";
|
|
import type { DetailFinancialRow, SurfaceFinancialRow } from "@/lib/types";
|
|
|
|
function createSurfaceRow(
|
|
input: Partial<SurfaceFinancialRow> &
|
|
Pick<SurfaceFinancialRow, "key" | "label" | "values">,
|
|
): SurfaceFinancialRow {
|
|
return {
|
|
key: input.key,
|
|
label: input.label,
|
|
category: input.category ?? "revenue",
|
|
order: input.order ?? 10,
|
|
unit: input.unit ?? "currency",
|
|
values: input.values,
|
|
sourceConcepts: input.sourceConcepts ?? [input.key],
|
|
sourceRowKeys: input.sourceRowKeys ?? [input.key],
|
|
sourceFactIds: input.sourceFactIds ?? [1],
|
|
formulaKey: input.formulaKey ?? null,
|
|
hasDimensions: input.hasDimensions ?? false,
|
|
resolvedSourceRowKeys:
|
|
input.resolvedSourceRowKeys ??
|
|
Object.fromEntries(
|
|
Object.keys(input.values).map((periodId) => [periodId, input.key]),
|
|
),
|
|
statement: input.statement ?? "income",
|
|
detailCount: input.detailCount,
|
|
resolutionMethod: input.resolutionMethod,
|
|
confidence: input.confidence,
|
|
warningCodes: input.warningCodes,
|
|
};
|
|
}
|
|
|
|
function createDetailRow(
|
|
input: Partial<DetailFinancialRow> &
|
|
Pick<DetailFinancialRow, "key" | "label" | "parentSurfaceKey" | "values">,
|
|
): DetailFinancialRow {
|
|
return {
|
|
key: input.key,
|
|
parentSurfaceKey: input.parentSurfaceKey,
|
|
label: input.label,
|
|
conceptKey: input.conceptKey ?? input.key,
|
|
qname: input.qname ?? `us-gaap:${input.key}`,
|
|
namespaceUri: input.namespaceUri ?? "http://fasb.org/us-gaap/2024",
|
|
localName: input.localName ?? input.key,
|
|
unit: input.unit ?? "USD",
|
|
values: input.values,
|
|
sourceFactIds: input.sourceFactIds ?? [100],
|
|
isExtension: input.isExtension ?? false,
|
|
dimensionsSummary: input.dimensionsSummary ?? [],
|
|
residualFlag: input.residualFlag ?? false,
|
|
};
|
|
}
|
|
|
|
describe("statement view model", () => {
|
|
const categories = [{ key: "opex", label: "Operating Expenses", count: 4 }];
|
|
|
|
it("builds a root-only tree when there are no configured children or details", () => {
|
|
const model = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows: [
|
|
createSurfaceRow({
|
|
key: "revenue",
|
|
label: "Revenue",
|
|
category: "revenue",
|
|
values: { p1: 100 },
|
|
}),
|
|
],
|
|
statementDetails: null,
|
|
categories: [],
|
|
searchQuery: "",
|
|
expandedRowKeys: new Set(),
|
|
});
|
|
|
|
expect(model.sections).toHaveLength(1);
|
|
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
|
kind: "surface",
|
|
row: { key: "revenue" },
|
|
expandable: false,
|
|
});
|
|
});
|
|
|
|
it("nests the operating expense child surfaces under the parent row", () => {
|
|
const model = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows: [
|
|
createSurfaceRow({
|
|
key: "operating_expenses",
|
|
label: "Operating Expenses",
|
|
category: "opex",
|
|
order: 10,
|
|
values: { p1: 40 },
|
|
}),
|
|
createSurfaceRow({
|
|
key: "selling_general_and_administrative",
|
|
label: "SG&A",
|
|
category: "opex",
|
|
order: 20,
|
|
values: { p1: 20 },
|
|
}),
|
|
createSurfaceRow({
|
|
key: "research_and_development",
|
|
label: "Research Expense",
|
|
category: "opex",
|
|
order: 30,
|
|
values: { p1: 12 },
|
|
}),
|
|
createSurfaceRow({
|
|
key: "other_operating_expense",
|
|
label: "Other Expense",
|
|
category: "opex",
|
|
order: 40,
|
|
values: { p1: 8 },
|
|
}),
|
|
],
|
|
statementDetails: null,
|
|
categories,
|
|
searchQuery: "",
|
|
expandedRowKeys: new Set(["operating_expenses"]),
|
|
});
|
|
|
|
const parent = model.sections[0]?.nodes[0];
|
|
expect(parent?.kind).toBe("surface");
|
|
const childKeys =
|
|
parent?.kind === "surface"
|
|
? parent.children.map((node) => node.row.key)
|
|
: [];
|
|
expect(childKeys).toEqual([
|
|
"selling_general_and_administrative",
|
|
"research_and_development",
|
|
"other_operating_expense",
|
|
]);
|
|
});
|
|
|
|
it("nests raw detail rows under the matching child surface row", () => {
|
|
const model = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows: [
|
|
createSurfaceRow({
|
|
key: "operating_expenses",
|
|
label: "Operating Expenses",
|
|
category: "opex",
|
|
order: 10,
|
|
values: { p1: 40 },
|
|
}),
|
|
createSurfaceRow({
|
|
key: "selling_general_and_administrative",
|
|
label: "SG&A",
|
|
category: "opex",
|
|
order: 20,
|
|
values: { p1: 20 },
|
|
}),
|
|
],
|
|
statementDetails: {
|
|
selling_general_and_administrative: [
|
|
createDetailRow({
|
|
key: "corporate_sga",
|
|
label: "Corporate SG&A",
|
|
parentSurfaceKey: "selling_general_and_administrative",
|
|
values: { p1: 20 },
|
|
}),
|
|
],
|
|
},
|
|
categories,
|
|
searchQuery: "",
|
|
expandedRowKeys: new Set([
|
|
"operating_expenses",
|
|
"selling_general_and_administrative",
|
|
]),
|
|
});
|
|
|
|
const child = model.sections[0]?.nodes[0];
|
|
expect(child?.kind).toBe("surface");
|
|
const sgaNode = child?.kind === "surface" ? child.children[0] : null;
|
|
expect(sgaNode?.kind).toBe("surface");
|
|
const detailNode = sgaNode?.kind === "surface" ? sgaNode.children[0] : null;
|
|
expect(detailNode).toMatchObject({
|
|
kind: "detail",
|
|
row: { key: "corporate_sga" },
|
|
});
|
|
});
|
|
|
|
it("dedupes raw detail rows that are already covered by a child surface concept", () => {
|
|
const rows = [
|
|
createSurfaceRow({
|
|
key: "operating_expenses",
|
|
label: "Operating Expenses",
|
|
category: "opex",
|
|
order: 10,
|
|
values: { p1: 40 },
|
|
}),
|
|
createSurfaceRow({
|
|
key: "research_and_development",
|
|
label: "Research Expense",
|
|
category: "opex",
|
|
order: 20,
|
|
values: { p1: 12 },
|
|
sourceConcepts: ["us-gaap:ResearchAndDevelopmentExpense"],
|
|
}),
|
|
];
|
|
const statementDetails = {
|
|
operating_expenses: [
|
|
createDetailRow({
|
|
key: "rnd_raw",
|
|
label: "Research And Development Expense",
|
|
parentSurfaceKey: "operating_expenses",
|
|
conceptKey: "research_and_development_expense",
|
|
qname: "us-gaap:ResearchAndDevelopmentExpense",
|
|
localName: "ResearchAndDevelopmentExpense",
|
|
values: { p1: 12 },
|
|
}),
|
|
createDetailRow({
|
|
key: "general_admin",
|
|
label: "General And Administrative Expense",
|
|
parentSurfaceKey: "operating_expenses",
|
|
conceptKey: "general_and_administrative_expense",
|
|
qname: "us-gaap:GeneralAndAdministrativeExpense",
|
|
localName: "GeneralAndAdministrativeExpense",
|
|
values: { p1: 7 },
|
|
}),
|
|
],
|
|
};
|
|
|
|
const model = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails,
|
|
categories,
|
|
searchQuery: "",
|
|
expandedRowKeys: new Set(["operating_expenses"]),
|
|
});
|
|
|
|
const parent = model.sections[0]?.nodes[0];
|
|
expect(parent?.kind).toBe("surface");
|
|
if (parent?.kind !== "surface") {
|
|
throw new Error("expected surface node");
|
|
}
|
|
|
|
expect(parent.directDetailCount).toBe(1);
|
|
expect(parent.children.map((node) => node.row.key)).toEqual([
|
|
"research_and_development",
|
|
"general_admin",
|
|
]);
|
|
expect(parent.children.some((node) => node.row.key === "rnd_raw")).toBe(
|
|
false,
|
|
);
|
|
expect(model.totalNodeCount).toBe(3);
|
|
});
|
|
|
|
it("returns the deduped detail rows for a selected surface", () => {
|
|
const rows = [
|
|
createSurfaceRow({
|
|
key: "operating_expenses",
|
|
label: "Operating Expenses",
|
|
category: "opex",
|
|
order: 10,
|
|
values: { p1: 40 },
|
|
}),
|
|
createSurfaceRow({
|
|
key: "research_and_development",
|
|
label: "Research Expense",
|
|
category: "opex",
|
|
order: 20,
|
|
values: { p1: 12 },
|
|
sourceConcepts: ["us-gaap:ResearchAndDevelopmentExpense"],
|
|
}),
|
|
];
|
|
const statementDetails = {
|
|
operating_expenses: [
|
|
createDetailRow({
|
|
key: "rnd_raw",
|
|
label: "Research And Development Expense",
|
|
parentSurfaceKey: "operating_expenses",
|
|
conceptKey: "research_and_development_expense",
|
|
qname: "us-gaap:ResearchAndDevelopmentExpense",
|
|
localName: "ResearchAndDevelopmentExpense",
|
|
values: { p1: 12 },
|
|
}),
|
|
createDetailRow({
|
|
key: "general_admin",
|
|
label: "General And Administrative Expense",
|
|
parentSurfaceKey: "operating_expenses",
|
|
conceptKey: "general_and_administrative_expense",
|
|
qname: "us-gaap:GeneralAndAdministrativeExpense",
|
|
localName: "GeneralAndAdministrativeExpense",
|
|
values: { p1: 7 },
|
|
}),
|
|
],
|
|
};
|
|
|
|
const selection = resolveStatementSelection({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails,
|
|
selection: { kind: "surface", key: "operating_expenses" },
|
|
});
|
|
|
|
expect(selection).toMatchObject({
|
|
kind: "surface",
|
|
detailRows: [{ key: "general_admin" }],
|
|
});
|
|
expect(
|
|
selection?.kind === "surface" &&
|
|
selection.detailRows.some((row) => row.key === "rnd_raw"),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("auto-expands the parent chain when search matches a child surface or detail row", () => {
|
|
const rows = [
|
|
createSurfaceRow({
|
|
key: "operating_expenses",
|
|
label: "Operating Expenses",
|
|
category: "opex",
|
|
order: 10,
|
|
values: { p1: 40 },
|
|
}),
|
|
createSurfaceRow({
|
|
key: "research_and_development",
|
|
label: "Research Expense",
|
|
category: "opex",
|
|
order: 20,
|
|
values: { p1: 12 },
|
|
}),
|
|
];
|
|
|
|
const childSearch = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails: null,
|
|
categories,
|
|
searchQuery: "research",
|
|
expandedRowKeys: new Set(),
|
|
});
|
|
|
|
expect(childSearch.autoExpandedKeys.has("operating_expenses")).toBe(true);
|
|
expect(
|
|
childSearch.sections[0]?.nodes[0]?.kind === "surface" &&
|
|
childSearch.sections[0]?.nodes[0].expanded,
|
|
).toBe(true);
|
|
|
|
const detailSearch = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails: {
|
|
research_and_development: [
|
|
createDetailRow({
|
|
key: "ai_lab_expense",
|
|
label: "AI Lab Expense",
|
|
parentSurfaceKey: "research_and_development",
|
|
values: { p1: 12 },
|
|
}),
|
|
],
|
|
},
|
|
categories,
|
|
searchQuery: "ai lab",
|
|
expandedRowKeys: new Set(),
|
|
});
|
|
|
|
const parent = detailSearch.sections[0]?.nodes[0];
|
|
const child = parent?.kind === "surface" ? parent.children[0] : null;
|
|
expect(parent?.kind === "surface" && parent.expanded).toBe(true);
|
|
expect(child?.kind === "surface" && child.expanded).toBe(true);
|
|
});
|
|
|
|
it("searches hidden duplicate concepts through the mapped child surface without reintroducing the raw detail row", () => {
|
|
const rows = [
|
|
createSurfaceRow({
|
|
key: "operating_expenses",
|
|
label: "Operating Expenses",
|
|
category: "opex",
|
|
order: 10,
|
|
values: { p1: 40 },
|
|
}),
|
|
createSurfaceRow({
|
|
key: "research_and_development",
|
|
label: "Research Expense",
|
|
category: "opex",
|
|
order: 20,
|
|
values: { p1: 12 },
|
|
sourceConcepts: ["us-gaap:ResearchAndDevelopmentExpense"],
|
|
}),
|
|
];
|
|
|
|
const model = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails: {
|
|
operating_expenses: [
|
|
createDetailRow({
|
|
key: "rnd_raw",
|
|
label: "Research And Development Expense",
|
|
parentSurfaceKey: "operating_expenses",
|
|
conceptKey: "research_and_development_expense",
|
|
qname: "us-gaap:ResearchAndDevelopmentExpense",
|
|
localName: "ResearchAndDevelopmentExpense",
|
|
values: { p1: 12 },
|
|
}),
|
|
],
|
|
},
|
|
categories,
|
|
searchQuery: "researchanddevelopmentexpense",
|
|
expandedRowKeys: new Set(),
|
|
});
|
|
|
|
const parent = model.sections[0]?.nodes[0];
|
|
expect(parent?.kind === "surface" && parent.expanded).toBe(true);
|
|
expect(
|
|
parent?.kind === "surface" && parent.children.map((node) => node.row.key),
|
|
).toEqual(["research_and_development"]);
|
|
});
|
|
|
|
it("does not throw when legacy surface rows are missing source arrays", () => {
|
|
const malformedRow = {
|
|
...createSurfaceRow({
|
|
key: "revenue",
|
|
label: "Revenue",
|
|
category: "revenue",
|
|
values: { p1: 100 },
|
|
}),
|
|
sourceConcepts: undefined,
|
|
sourceRowKeys: undefined,
|
|
} as unknown as SurfaceFinancialRow;
|
|
|
|
const model = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows: [malformedRow],
|
|
statementDetails: null,
|
|
categories: [],
|
|
searchQuery: "revenue",
|
|
expandedRowKeys: new Set(),
|
|
});
|
|
|
|
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
|
kind: "surface",
|
|
row: { key: "revenue" },
|
|
});
|
|
});
|
|
|
|
it("keeps not meaningful rows visible and resolves selections for surface and detail nodes", () => {
|
|
const rows = [
|
|
createSurfaceRow({
|
|
key: "gross_profit",
|
|
label: "Gross Profit",
|
|
category: "profit",
|
|
values: { p1: null },
|
|
resolutionMethod: "not_meaningful",
|
|
warningCodes: ["gross_profit_not_meaningful_bank_pack"],
|
|
}),
|
|
];
|
|
const details = {
|
|
gross_profit: [
|
|
createDetailRow({
|
|
key: "gp_unmapped",
|
|
label: "Gross Profit residual",
|
|
parentSurfaceKey: "gross_profit",
|
|
values: { p1: null },
|
|
residualFlag: true,
|
|
}),
|
|
],
|
|
};
|
|
|
|
const model = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails: details,
|
|
categories: [],
|
|
searchQuery: "",
|
|
expandedRowKeys: new Set(["gross_profit"]),
|
|
});
|
|
|
|
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
|
kind: "surface",
|
|
row: { key: "gross_profit", resolutionMethod: "not_meaningful" },
|
|
});
|
|
|
|
const surfaceSelection = resolveStatementSelection({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails: details,
|
|
selection: { kind: "surface", key: "gross_profit" },
|
|
});
|
|
expect(surfaceSelection?.kind).toBe("surface");
|
|
|
|
const detailSelection = resolveStatementSelection({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails: details,
|
|
selection: {
|
|
kind: "detail",
|
|
key: "gp_unmapped",
|
|
parentKey: "gross_profit",
|
|
},
|
|
});
|
|
expect(detailSelection).toMatchObject({
|
|
kind: "detail",
|
|
row: { key: "gp_unmapped", residualFlag: true },
|
|
});
|
|
});
|
|
|
|
it("renders unmapped detail rows in a dedicated residual section and counts them", () => {
|
|
const model = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows: [
|
|
createSurfaceRow({
|
|
key: "revenue",
|
|
label: "Revenue",
|
|
category: "revenue",
|
|
values: { p1: 100 },
|
|
}),
|
|
],
|
|
statementDetails: {
|
|
unmapped: [
|
|
createDetailRow({
|
|
key: "unmapped_other_income",
|
|
label: "Other income residual",
|
|
parentSurfaceKey: "unmapped",
|
|
values: { p1: 5 },
|
|
residualFlag: true,
|
|
}),
|
|
],
|
|
},
|
|
categories: [],
|
|
searchQuery: "",
|
|
expandedRowKeys: new Set(),
|
|
});
|
|
|
|
expect(model.sections).toHaveLength(2);
|
|
expect(model.sections[1]).toMatchObject({
|
|
key: "unmapped_residual",
|
|
label: "Unmapped / Residual",
|
|
});
|
|
expect(model.sections[1]?.nodes[0]).toMatchObject({
|
|
kind: "detail",
|
|
row: { key: "unmapped_other_income", parentSurfaceKey: "unmapped" },
|
|
});
|
|
expect(model.visibleNodeCount).toBe(2);
|
|
expect(model.totalNodeCount).toBe(2);
|
|
});
|
|
|
|
it("matches search and resolves selection for unmapped detail rows without a real parent surface", () => {
|
|
const rows = [
|
|
createSurfaceRow({
|
|
key: "revenue",
|
|
label: "Revenue",
|
|
category: "revenue",
|
|
values: { p1: 100 },
|
|
}),
|
|
];
|
|
const statementDetails = {
|
|
unmapped: [
|
|
createDetailRow({
|
|
key: "unmapped_fx_gain",
|
|
label: "FX gain residual",
|
|
parentSurfaceKey: "unmapped",
|
|
values: { p1: 2 },
|
|
residualFlag: true,
|
|
}),
|
|
],
|
|
};
|
|
|
|
const model = buildStatementTree({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails,
|
|
categories: [],
|
|
searchQuery: "fx gain",
|
|
expandedRowKeys: new Set(),
|
|
});
|
|
|
|
expect(model.sections).toHaveLength(1);
|
|
expect(model.sections[0]).toMatchObject({
|
|
key: "unmapped_residual",
|
|
label: "Unmapped / Residual",
|
|
});
|
|
expect(model.visibleNodeCount).toBe(1);
|
|
expect(model.totalNodeCount).toBe(2);
|
|
|
|
const selection = resolveStatementSelection({
|
|
surfaceKind: "income_statement",
|
|
rows,
|
|
statementDetails,
|
|
selection: {
|
|
kind: "detail",
|
|
key: "unmapped_fx_gain",
|
|
parentKey: "unmapped",
|
|
},
|
|
});
|
|
|
|
expect(selection).toMatchObject({
|
|
kind: "detail",
|
|
row: { key: "unmapped_fx_gain", parentSurfaceKey: "unmapped" },
|
|
parentSurfaceRow: null,
|
|
});
|
|
});
|
|
});
|