diff --git a/docs/rendering.md b/docs/rendering.md index 17da3b4..fe90b8d 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -108,6 +108,11 @@ reference summaries list checked and uncovered outputs; they do not claim that an external algorithm was independently evaluated. Older captures retain their original meaning. +A comparison's case headings show the captured case subscript, such as `fp` +for `first_panel`, so table values can be matched to downstream qualified +symbols. Row-oriented comparisons show it beside each case label. Unqualified +cases keep their ordinary headings; no suffix is inferred from the display title. + A comparison is one logical table. On screen it scrolls horizontally and keeps the identifying column visible. Print divides the authored column axis into parts that fit the page width, then continues a tall part onto later pages. diff --git a/packages/cso-core/src/contracts/glyphs.ts b/packages/cso-core/src/contracts/glyphs.ts index 67cf2d0..f694c20 100644 --- a/packages/cso-core/src/contracts/glyphs.ts +++ b/packages/cso-core/src/contracts/glyphs.ts @@ -64,9 +64,9 @@ export const addSymbolDisplayIssues = ( } }; -export const scopedGlyph = (glyph: string, scope: string): string => { - // Keep full binding names in evidence and compact initials in the document. - const displayScope = scope +/** Keep full binding names in evidence and compact initials in the document. */ +export const compactGlyphScope = (scope: string): string => + scope .split(',') .map((binding) => { const words = binding.split('_').filter(Boolean); @@ -75,6 +75,9 @@ export const scopedGlyph = (glyph: string, scope: string): string => { : binding; }) .join(','); + +export const scopedGlyph = (glyph: string, scope: string): string => { + const displayScope = compactGlyphScope(scope); const subscript = subscriptPattern.exec(glyph); if (subscript) { return `${subscript[1]}_{${subscript[2] ?? subscript[3]},${displayScope}}`; diff --git a/packages/cso-core/src/index.ts b/packages/cso-core/src/index.ts index 54b7290..3a127f9 100644 --- a/packages/cso-core/src/index.ts +++ b/packages/cso-core/src/index.ts @@ -129,7 +129,11 @@ export { assertNever } from './shared/assertNever.ts'; export { formatNumerical } from './shared/formatNumerical.ts'; export type { NumericKind } from './contracts/numbers.ts'; -export { scopedGlyph, symbolDisplayIdentity } from './contracts/glyphs.ts'; +export { + compactGlyphScope, + scopedGlyph, + symbolDisplayIdentity, +} from './contracts/glyphs.ts'; export { notationIdentity } from './notation/identity.ts'; export { parseNotation } from './notation/parse.ts'; export type { diff --git a/packages/cso-core/tests/glyphs.test.ts b/packages/cso-core/tests/glyphs.test.ts index 7126c47..ff06a6c 100644 --- a/packages/cso-core/tests/glyphs.test.ts +++ b/packages/cso-core/tests/glyphs.test.ts @@ -1,6 +1,16 @@ import { expect, it } from 'vitest'; -import { symbolDisplayIdentity, scopedGlyph } from '../src/contracts/glyphs.ts'; +import { + compactGlyphScope, + scopedGlyph, + symbolDisplayIdentity, +} from '../src/contracts/glyphs.ts'; + const glyphIdentity = (glyph: string) => symbolDisplayIdentity({ glyph }); +it('uses the same compact scope in case headings and qualified glyphs', () => { + expect(compactGlyphScope('case_alpha')).toBe('ca'); + expect(compactGlyphScope('outer_frame,case_alpha')).toBe('of,ca'); + expect(compactGlyphScope('𐐀_panel')).toBe('𐐀p'); +}); it('preserves existing scripts and distinguishes full nested call paths', () => { expect(scopedGlyph('A_s', 'a,child')).toBe('A_{s,a,child}'); expect(scopedGlyph('A_{s}', 'b,child')).toBe('A_{s,b,child}'); diff --git a/packages/cso-react/src/PreparedFormulaSheet.tsx b/packages/cso-react/src/PreparedFormulaSheet.tsx index ee85ec5..f8f6ae1 100644 --- a/packages/cso-react/src/PreparedFormulaSheet.tsx +++ b/packages/cso-react/src/PreparedFormulaSheet.tsx @@ -13,16 +13,17 @@ import { } from '@cs-object/core'; import { Fragment, type ReactElement, type ReactNode } from 'react'; import { EngineeringMetadata } from './EngineeringMetadata.tsx'; +import { FormulaSheetItemIndex } from './formula-sheet/FormulaSheetItemIndex.tsx'; import { + bindingOrigin, FormulaSheetSymbolRow, type FormulaSheetSymbolRowProps, - bindingOrigin, } from './formula-sheet/FormulaSheetSymbolRow.tsx'; -import { FormulaSheetItemIndex } from './formula-sheet/FormulaSheetItemIndex.tsx'; import { NumericalValue } from './formula-sheet/NumericalValue.tsx'; import { GlyphMathBlock } from './mathml/GlyphMathBlock.tsx'; import { InlineFormulaMath } from './mathml/InlineFormulaMath.tsx'; import { UnitMathBlock } from './mathml/UnitMathBlock.tsx'; +import { tableCaseQualifier } from './table-case-notation.ts'; export interface PreparedFormulaSheetProps { readonly document: PreparedDocument; @@ -198,6 +199,14 @@ export const PreparedFormulaSheet = ({ document.assets.map((asset) => [asset.asset.id, asset]), ); const symbols = new Map(sheet.symbols.map((symbol) => [symbol.id, symbol])); + const capturedSymbols = [ + ...document.detachedSymbols, + ...document.sections.flatMap((section) => + section.items.flatMap((item) => + item.kind === 'symbol' ? [item.symbol] : [], + ), + ), + ]; let symbolIndex = 0; const renderMethodContent = ( content: readonly PreparedMethodContent[], @@ -724,6 +733,25 @@ export const PreparedFormulaSheet = ({ ); } case 'table': { + const caseHeading = (caseItem: (typeof item.cases)[number]) => { + const qualifier = tableCaseQualifier( + caseItem.invocationId, + capturedSymbols, + ); + return ( + <> + {caseItem.heading} + {qualifier ? ( + + Subscript: + + ) : null} + + ); + }; const outcomeRows = item.outcomeRows ?? []; const rowIndices = new Map(); for (const group of item.groups) @@ -1085,7 +1113,7 @@ export const PreparedFormulaSheet = ({ data-comparison-label="" data-case-heading={caseItem.heading} > - {caseItem.heading} + {caseHeading(caseItem)} {item.groups.flatMap((group) => group.rows.map((row) => { @@ -1154,7 +1182,7 @@ export const PreparedFormulaSheet = ({ data-case-heading={caseItem.heading} data-comparison-column={index} > - {caseItem.heading} + {caseHeading(caseItem)} ))} diff --git a/packages/cso-react/src/styles.css b/packages/cso-react/src/styles.css index d506dfe..fe453ad 100644 --- a/packages/cso-react/src/styles.css +++ b/packages/cso-react/src/styles.css @@ -172,6 +172,8 @@ math { .cso-comparison td[data-outcome-kind] { text-align: center; white-space: normal; } .cso-comparison th[data-case-heading] { text-align: right; } .cso-comparison tbody th[data-case-heading] { text-align: left; } +.cso-comparison-case-qualifier { display: flex; align-items: baseline; justify-content: flex-end; gap: 0.5ch; font-size: 0.9em; font-weight: 400; color: #475467; } +.cso-comparison tbody .cso-comparison-case-qualifier { justify-content: flex-start; } .cso-comparison th[scope="colgroup"] { background: #f2f4f7; color: #344054; font-size: 10px; } .cso-comparison [data-comparison-label] { position: sticky; left: 0; z-index: 1; background: white; } .cso-comparison thead [data-comparison-label] { background: #f8fafc; } diff --git a/packages/cso-react/src/table-case-notation.ts b/packages/cso-react/src/table-case-notation.ts new file mode 100644 index 0000000..cb1a588 --- /dev/null +++ b/packages/cso-react/src/table-case-notation.ts @@ -0,0 +1,31 @@ +import { + type CalculationSourceSymbol, + compactGlyphScope, +} from '@cs-object/core'; + +/** + * Read a case's qualifier from captured notation, including quantities returned + * directly by a nested helper. Unqualified quantities supply no suffix. + */ +export const tableCaseQualifier = ( + invocationId: string, + symbols: readonly CalculationSourceSymbol[], +): string | undefined => { + for (const symbol of symbols) { + const owner = symbol.metadata?.invocationId; + const scope = symbol.metadata?.glyphScope; + if (typeof owner !== 'string' || typeof scope !== 'string') continue; + if (owner !== invocationId && !owner.startsWith(`${invocationId}/`)) + continue; + const nestedDepth = + owner === invocationId + ? 0 + : owner.slice(invocationId.length + 1).split('/').length; + const bindings = scope.split(','); + if (bindings.length <= nestedDepth) continue; + return compactGlyphScope( + bindings.slice(0, bindings.length - nestedDepth).join(','), + ); + } + return undefined; +}; diff --git a/packages/cso-react/tests/table-case-notation.test.ts b/packages/cso-react/tests/table-case-notation.test.ts new file mode 100644 index 0000000..0ae4913 --- /dev/null +++ b/packages/cso-react/tests/table-case-notation.test.ts @@ -0,0 +1,59 @@ +import { + type CalculationSourceSymbol, + CalculationSourceSymbolSchema, +} from '@cs-object/core'; +import { expect, it } from 'vitest'; +import { tableCaseQualifier } from '../src/table-case-notation.ts'; + +const quantity = ( + invocationId: string, + glyphScope?: string, +): CalculationSourceSymbol => + CalculationSourceSymbolSchema.parse({ + id: invocationId, + glyph: 'u_{ax}', + valueTree: { + rootKey: 'value', + nodes: [ + { + key: 'value', + mode: 'LITERAL', + literal: { kind: 'number', value: 1 }, + }, + ], + }, + metadata: { + invocationId, + ...(glyphScope === undefined ? {} : { glyphScope }), + }, + }); + +it('uses captured scopes for nested cases instead of the display heading', () => { + const symbols = [ + quantity('root/outer_frame/case_alpha', 'outer_frame,case_alpha'), + ]; + expect(tableCaseQualifier('root/outer_frame/case_alpha', symbols)).toBe( + 'of,ca', + ); +}); + +it('retains the case prefix for an output forwarded from a nested helper', () => { + const symbols = [ + quantity('root/case_alpha/inner_response', 'case_alpha,inner_response'), + ]; + expect(tableCaseQualifier('root/case_alpha', symbols)).toBe('ca'); +}); + +it('does not borrow a qualifier from a shared caller or a similarly named case', () => { + const symbols = [ + quantity('root/parent_case', 'parent_case'), + quantity('root/case_alpha_extra', 'case_alpha_extra'), + ]; + expect(tableCaseQualifier('root/case_alpha', symbols)).toBeUndefined(); +}); + +it('does not invent a suffix for unqualified or older quantities', () => { + expect( + tableCaseQualifier('root/case_alpha', [quantity('root/case_alpha')]), + ).toBeUndefined(); +}); diff --git a/tests/integration/method-table.test.ts b/tests/integration/method-table.test.ts index 728423a..275105b 100644 --- a/tests/integration/method-table.test.ts +++ b/tests/integration/method-table.test.ts @@ -714,6 +714,12 @@ test('publishes one straight-line method and bound case columns', async () => { ); expect(htmlText).toContain('data-case-heading="Case A"'); expect(htmlText).toContain('data-case-heading="Case B"'); + expect(elementWith(htmlText, 'data-case-heading="Case A"')).toContain( + 'data-case-qualifier="fp"', + ); + expect(elementWith(htmlText, 'data-case-heading="Case B"')).toContain( + 'data-case-qualifier="sp"', + ); expect(htmlText).toContain('(w * h)'); const sentinelPath = join(directory, 'sentinel.pdf'); @@ -765,6 +771,9 @@ test('publishes one straight-line method and bound case columns', async () => { 'scope="row" data-comparison-label="" data-case-heading="Case A"', ); expect(rowHtmlText).toContain('scope="col" data-quantity="area"'); + expect(elementWith(rowHtmlText, 'data-case-heading="Case A"')).toContain( + 'data-case-qualifier="fp"', + ); const rowPdfPath = join(directory, 'rows.pdf'); const rowPdf = publish('pdf', 'rows_publish.cso.py', 'compare', rowPdfPath); expect(rowPdf.status, rowPdf.stdout || rowPdf.stderr).toBe(0); @@ -814,6 +823,14 @@ test('typesets methods, notes and table values like ordinary formula rows', () = const areaHeading = elementWith(html, 'scope="row" data-quantity="area"'); expect(areaHeading).toContain(' symbol.glyph === 'A_{fp}'), + ).toBe(true); const branchDocument = prepareExecutionDocument({ execution: capture('branch_compare.cso.py', 'compare'), @@ -1369,6 +1386,7 @@ test('continues a wide comparison across measured column groups', () => { expect(text.status, text.stderr).toBe(0); expect(text.stdout).toContain('Part 1 of 3'); expect(text.stdout).toContain('Part 3 of 3'); + expect(text.stdout.match(/Subscript:/g)?.length).toBeGreaterThanOrEqual(3); expect(text.stdout).toContain('Not applicable'); expect(text.stdout).toContain('Perimeter exceeds twelve metres.'); expect(text.stdout).toContain('m2'); @@ -1384,6 +1402,7 @@ test('continues a wide comparison across measured column groups', () => { expect(rowsText.stdout).toContain('Part 1 of'); expect(rowsText.stdout).toContain('Not applicable'); expect(rowsText.stdout.match(/Case A/g)?.length).toBeGreaterThan(1); + expect(rowsText.stdout.match(/Subscript:/g)?.length).toBeGreaterThan(1); const sentinel = join(directory, 'unfit-sentinel.pdf'); writeFileSync(sentinel, 'keep');