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 & Pick): 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 & Pick): 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 }); }); });