318 lines
11 KiB
TypeScript
318 lines
11 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('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('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
|
|
});
|
|
});
|
|
});
|