diff --git a/package-lock.json b/package-lock.json index 806a73b..7e08d00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-sns": "^3.1041.0", - "@robosystems/client": "0.3.28", + "@robosystems/client": "0.3.33", "flowbite": "^3.1", "flowbite-react": "^0.12.5", "intuit-oauth": "^4.1.0", @@ -3288,9 +3288,9 @@ } }, "node_modules/@robosystems/client": { - "version": "0.3.28", - "resolved": "https://registry.npmjs.org/@robosystems/client/-/client-0.3.28.tgz", - "integrity": "sha512-VAABFXm0HAzkTNM6u7DmZabBRBnJd50PYIeSHhxSIh2E4mMS88ArGN6TBGb4HWt9kQCqkS1wbNruM41AOJS4Ng==", + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@robosystems/client/-/client-0.3.33.tgz", + "integrity": "sha512-JIu6PbH7s5UXN0laE0adtsB+Fxv4Mf4Ls85haC8LQNYQGMLfehaAtFQ+jhiF3mY9wGyrICR3Osq8NhiGlVOpLA==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", diff --git a/package.json b/package.json index ac4cc47..634fc0a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ }, "dependencies": { "@aws-sdk/client-sns": "^3.1041.0", - "@robosystems/client": "0.3.28", + "@robosystems/client": "0.3.33", "flowbite": "^3.1", "flowbite-react": "^0.12.5", "intuit-oauth": "^4.1.0", diff --git a/src/app/(app)/ledger/close/components/blockview/__tests__/VerificationResults.test.tsx b/src/app/(app)/ledger/close/components/blockview/__tests__/VerificationResults.test.tsx new file mode 100644 index 0000000..9da12f2 --- /dev/null +++ b/src/app/(app)/ledger/close/components/blockview/__tests__/VerificationResults.test.tsx @@ -0,0 +1,148 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import VerificationResults from '../projections/VerificationResults' +import { makeEnvelope } from './_envelope-fixtures' + +// The shared fixtures don't ship rule/result builders — construct minimal +// shapes (cast, like the other fixtures) carrying only the fields the +// projection reads. +const rule = (id: string, ruleCategory: string, ruleMessage: string) => + ({ id, ruleCategory, ruleExpression: '$A = $B', ruleMessage }) as never + +const result = (id: string, ruleId: string, status: string) => + ({ + id, + ruleId, + status, + structureId: null, + factSetId: null, + message: null, + periodStart: null, + periodEnd: null, + evaluatedAt: null, + }) as never + +// FAC category: one pass + one fail (→ expanded by default). +// Consistency category: one pass (→ collapsed by default). +const envelopeWithVerification = () => + makeEnvelope({ + rules: [ + rule('r1', 'FundamentalAccountingConceptRelation', 'BS balances'), + rule('r2', 'FundamentalAccountingConceptRelation', 'Rollup check'), + rule('r3', 'Consistency', 'Consistency check'), + ], + verificationResults: [ + result('vr1', 'r1', 'pass'), + result('vr2', 'r2', 'fail'), + result('vr3', 'r3', 'pass'), + ], + verificationSummary: { + total: 3, + passed: 2, + failed: 1, + errored: 0, + skipped: 0, + byCategory: [ + { + category: 'Consistency', + total: 1, + passed: 1, + failed: 0, + errored: 0, + skipped: 0, + }, + { + category: 'FundamentalAccountingConceptRelation', + total: 2, + passed: 1, + failed: 1, + errored: 0, + skipped: 0, + }, + ], + }, + } as never) + +describe('VerificationResults projection (§7.12 category grouping)', () => { + it('shows the empty state when there are no rule evaluations', () => { + render() + expect(screen.getByText(/no rule evaluations/i)).toBeInTheDocument() + }) + + it('renders the overall tally from verificationSummary + humanized category headers', () => { + render() + // Overall tally driven by the server-computed summary. + expect(screen.getByText('2 Pass')).toBeInTheDocument() + expect(screen.getByText('1 Fail')).toBeInTheDocument() + // PascalCase rule_category humanized for display. + expect(screen.getByText('Consistency')).toBeInTheDocument() + expect( + screen.getByText('Fundamental Accounting Concept Relation') + ).toBeInTheDocument() + }) + + it('expands a failing category by default and keeps a clean one collapsed until clicked', () => { + render() + + // FAC has a failure → expanded → its failing rule is visible. + expect(screen.getByText('Rollup check')).toBeInTheDocument() + // Consistency is all-pass → collapsed → its rule is hidden. + expect(screen.queryByText('Consistency check')).toBeNull() + + // Clicking the clean category header expands it. + fireEvent.click(screen.getByText('Consistency')) + expect(screen.getByText('Consistency check')).toBeInTheDocument() + }) + + it('falls back to an in-memory tally when verificationSummary is absent', () => { + // Older envelopes (pre-summary SDK) carry results but no summary; the + // overall tally must roll up from the grouped results instead. + const env = makeEnvelope({ + rules: [rule('r1', 'Consistency', 'BS balances')], + verificationResults: [ + result('vr1', 'r1', 'pass'), + result('vr2', 'r1', 'fail'), + ], + verificationSummary: null, + } as never) + render() + expect(screen.getByText('1 Pass')).toBeInTheDocument() + expect(screen.getByText('1 Fail')).toBeInTheDocument() + }) + + it('renders error and skipped statuses with their tallies', () => { + const env = makeEnvelope({ + rules: [ + rule('r1', 'Consistency', 'errored rule'), + rule('r2', 'Consistency', 'skipped rule'), + ], + verificationResults: [ + result('vr1', 'r1', 'error'), + result('vr2', 'r2', 'skipped'), + ], + verificationSummary: { + total: 2, + passed: 0, + failed: 0, + errored: 1, + skipped: 1, + byCategory: [ + { + category: 'Consistency', + total: 2, + passed: 0, + failed: 0, + errored: 1, + skipped: 1, + }, + ], + }, + } as never) + render() + expect(screen.getByText('1 Error')).toBeInTheDocument() + expect(screen.getByText('1 Skipped')).toBeInTheDocument() + // An error makes the category problematic → expanded → its row visible. + expect(screen.getByText('errored rule')).toBeInTheDocument() + }) +}) diff --git a/src/app/(app)/ledger/close/components/blockview/__tests__/_envelope-fixtures.ts b/src/app/(app)/ledger/close/components/blockview/__tests__/_envelope-fixtures.ts index f9de8a3..56ff47a 100644 --- a/src/app/(app)/ledger/close/components/blockview/__tests__/_envelope-fixtures.ts +++ b/src/app/(app)/ledger/close/components/blockview/__tests__/_envelope-fixtures.ts @@ -113,6 +113,7 @@ export const makeEnvelope = ( rules: [], factSet: null, verificationResults: [], + verificationSummary: null, view: { rendering: makeRendering() }, ...overrides, }) as EnvelopeBlock diff --git a/src/app/(app)/ledger/close/components/blockview/projections/VerificationResults.tsx b/src/app/(app)/ledger/close/components/blockview/projections/VerificationResults.tsx index 55a4373..117f31b 100644 --- a/src/app/(app)/ledger/close/components/blockview/projections/VerificationResults.tsx +++ b/src/app/(app)/ledger/close/components/blockview/projections/VerificationResults.tsx @@ -2,9 +2,11 @@ import { Badge } from 'flowbite-react' import type { FC } from 'react' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { HiCheckCircle, + HiChevronDown, + HiChevronRight, HiExclamation, HiExclamationCircle, HiMinusCircle, @@ -67,19 +69,41 @@ function formatPeriod(row: EnvelopeVerificationResult): string { return `${row.periodStart} → ${row.periodEnd}` } +// `rule_category` values are PascalCase ontology terms +// (e.g. `FundamentalAccountingConceptRelation`); space them for display. +function humanizeCategory(category: string): string { + if (!category) return 'Uncategorized' + return category + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') +} + +interface CategoryGroup { + category: string + results: EnvelopeVerificationResult[] + counts: Record + total: number +} + /** - * Charlie's `VerificationResults` View projection (financial-viewer.md §4.3). + * Charlie's `VerificationResults` View projection (financial-viewer.md §4.3, + * §7.12 restructure). * * Uniform across every block type — surfaces the outcome of every rule - * evaluation tied to this block's `(structure, fact_set)` pair, grouped - * by status with failures first. The rule's metadata (pattern, severity, - * message) is joined in-memory from `envelope.rules[]` by `ruleId`; the - * verification row itself carries only the foreign key + outcome. + * evaluation tied to this block's `(structure, fact_set)` pair. Rules are + * grouped into per-`rule_category` accordions (the §7.12 two-level layout): + * categories with failures or errors expand by default; clean categories + * collapse so the eye lands on what needs attention. Each rule's metadata + * (category, pattern, severity, message) is joined in-memory from + * `envelope.rules[]` by `ruleId`; the verification row carries only the + * foreign key + outcome. * - * The backend's rule engine auto-runs on every saved-report and - * period-close mutation (roadmap §3.8), so this projection reflects the - * current state of the block's invariants without needing a manual - * `POST /evaluate-rules` call. + * The overall tally is driven by the server-computed `verificationSummary` + * arm when present (it pre-joins category + aggregates), falling back to an + * in-memory roll-up of `verificationResults` for older envelopes. The rule + * engine auto-runs on saved-report and period-close mutations (roadmap §3.8), + * so this reflects the block's current invariants without a manual + * `POST /evaluate-rules`. */ const VerificationResultsProjection: FC = ({ envelope, @@ -89,26 +113,59 @@ const VerificationResultsProjection: FC = ({ [envelope.rules] ) - const grouped = useMemo>(() => { - const groups = new Map() + // Group results by rule_category (joined from rules by ruleId), then sort + // by category name to match the backend's by_category ordering. We group + // in-memory rather than reading `verificationSummary.byCategory`: the panel + // needs the result rows grouped anyway, and deriving the per-category counts + // from those same groups guarantees the accordion headers can't drift from + // the rows shown. (byCategory + the top-level summary stay useful for + // lighter consumers — e.g. list badges — that don't fetch the full rows.) + const groups = useMemo(() => { + const byCategory = new Map() for (const result of envelope.verificationResults) { - const status = normalizeStatus(result.status) - const arr = groups.get(status) ?? [] + const category = + rulesById.get(result.ruleId)?.ruleCategory ?? 'Uncategorized' + const arr = byCategory.get(category) ?? [] arr.push(result) - groups.set(status, arr) + byCategory.set(category, arr) } - return groups - }, [envelope.verificationResults]) + return Array.from(byCategory.entries()) + .map(([category, results]) => { + const counts: Record = { + pass: 0, + fail: 0, + error: 0, + skipped: 0, + } + for (const r of results) counts[normalizeStatus(r.status)] += 1 + return { category, results, counts, total: results.length } + }) + .sort((a, b) => a.category.localeCompare(b.category)) + }, [envelope.verificationResults, rulesById]) - const totals = useMemo(() => { + // Overall tally — prefer the server-computed summary; fall back to an + // in-memory roll-up for envelopes from SDKs before the summary arm. + const totals = useMemo>(() => { + const summary = envelope.verificationSummary + if (summary) { + return { + pass: summary.passed, + fail: summary.failed, + error: summary.errored, + skipped: summary.skipped, + } + } const counts: Record = { - pass: grouped.get('pass')?.length ?? 0, - fail: grouped.get('fail')?.length ?? 0, - error: grouped.get('error')?.length ?? 0, - skipped: grouped.get('skipped')?.length ?? 0, + pass: 0, + fail: 0, + error: 0, + skipped: 0, + } + for (const group of groups) { + for (const status of STATUS_ORDER) counts[status] += group.counts[status] } return counts - }, [grouped]) + }, [envelope.verificationSummary, groups]) if (envelope.verificationResults.length === 0) { return ( @@ -120,8 +177,8 @@ const VerificationResultsProjection: FC = ({ } return ( -
- {/* Status tally header */} +
+ {/* Overall status tally */}
{STATUS_ORDER.filter((s) => totals[s] > 0).map((status) => { const badge = STATUS_BADGE[status] @@ -133,52 +190,89 @@ const VerificationResultsProjection: FC = ({ })}
- {/* Per-status sections */} - {STATUS_ORDER.map((status) => { - const results = grouped.get(status) - if (!results || results.length === 0) return null - return ( - - ) - })} + {/* Per-category accordions */} + {groups.map((group) => ( + + ))}
) } -interface StatusSectionProps { - status: Status - results: EnvelopeVerificationResult[] +interface CategorySectionProps { + group: CategoryGroup rulesById: Map } -const StatusSection: FC = ({ - status, - results, - rulesById, -}) => { - const StatusIconComp = STATUS_ICON[status] - const badge = STATUS_BADGE[status] +const CategorySection: FC = ({ group, rulesById }) => { + const { counts, total } = group + const hasProblems = counts.fail > 0 || counts.error > 0 + const [open, setOpen] = useState(hasProblems) + + // Header status icon: failures/errors → alert; skips-only → muted; else ✓. + const HeaderIcon = hasProblems + ? counts.fail > 0 + ? HiExclamationCircle + : HiExclamation + : counts.skipped > 0 && counts.pass === 0 + ? HiMinusCircle + : HiCheckCircle + const headerIconTone = hasProblems + ? counts.fail > 0 + ? 'text-red-500 dark:text-red-400' + : 'text-amber-500 dark:text-amber-400' + : counts.skipped > 0 && counts.pass === 0 + ? 'text-gray-400' + : 'text-emerald-500 dark:text-emerald-400' + + // "9 of 10 passed, 1 failed" — failures/errors/skips appended when present. + const summaryParts: string[] = [`${counts.pass} of ${total} passed`] + if (counts.fail > 0) summaryParts.push(`${counts.fail} failed`) + if (counts.error > 0) { + summaryParts.push(`${counts.error} error${counts.error === 1 ? '' : 's'}`) + } + if (counts.skipped > 0) summaryParts.push(`${counts.skipped} skipped`) + const Chevron = open ? HiChevronDown : HiChevronRight + // Stable id linking the toggle button to the panel it controls (a11y). + const panelId = `verification-category-${group.category}` + return ( -
-
- - {badge.label} ({results.length}) -
-
    - {results.map((result) => ( - - ))} -
+
+ + {open && ( +
    + {STATUS_ORDER.flatMap((status) => + group.results + .filter((r) => normalizeStatus(r.status) === status) + .map((result) => ( + + )) + )} +
+ )}
) } diff --git a/src/app/(app)/ledger/close/components/blockview/types.ts b/src/app/(app)/ledger/close/components/blockview/types.ts index 2037892..0305c50 100644 --- a/src/app/(app)/ledger/close/components/blockview/types.ts +++ b/src/app/(app)/ledger/close/components/blockview/types.ts @@ -19,6 +19,9 @@ export type EnvelopeConnection = InformationBlock['connections'][number] export type EnvelopeRule = InformationBlock['rules'][number] export type EnvelopeVerificationResult = InformationBlock['verificationResults'][number] +export type EnvelopeVerificationSummary = NonNullable< + InformationBlock['verificationSummary'] +> export type EnvelopeView = InformationBlock['view'] export type EnvelopeRendering = NonNullable export type EnvelopeRenderingRow = EnvelopeRendering['rows'][number]