Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions packages/cso-core/src/contracts/glyphs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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}}`;
Expand Down
6 changes: 5 additions & 1 deletion packages/cso-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion packages/cso-core/tests/glyphs.test.ts
Original file line number Diff line number Diff line change
@@ -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}');
Expand Down
36 changes: 32 additions & 4 deletions packages/cso-react/src/PreparedFormulaSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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 ? (
<span
className="cso-comparison-case-qualifier"
data-case-qualifier={qualifier}
>
Subscript: <GlyphMathBlock glyph={qualifier} />
</span>
) : null}
</>
);
};
const outcomeRows = item.outcomeRows ?? [];
const rowIndices = new Map<string, number>();
for (const group of item.groups)
Expand Down Expand Up @@ -1085,7 +1113,7 @@ export const PreparedFormulaSheet = ({
data-comparison-label=""
data-case-heading={caseItem.heading}
>
{caseItem.heading}
{caseHeading(caseItem)}
</th>
{item.groups.flatMap((group) =>
group.rows.map((row) => {
Expand Down Expand Up @@ -1154,7 +1182,7 @@ export const PreparedFormulaSheet = ({
data-case-heading={caseItem.heading}
data-comparison-column={index}
>
{caseItem.heading}
{caseHeading(caseItem)}
</th>
))}
</tr>
Expand Down
2 changes: 2 additions & 0 deletions packages/cso-react/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
31 changes: 31 additions & 0 deletions packages/cso-react/src/table-case-notation.ts
Original file line number Diff line number Diff line change
@@ -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;
};
59 changes: 59 additions & 0 deletions packages/cso-react/tests/table-case-notation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
19 changes: 19 additions & 0 deletions tests/integration/method-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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('<msup');
expect(areaHeading).not.toContain('m^2');
const firstCase = elementWith(html, 'data-case-heading="Case A"');
expect(firstCase).toContain('Subscript:');
expect(firstCase).toContain('data-case-qualifier="fp"');
expect(firstCase).toContain('<math');
// Captured downstream notation uses the same suffix as the header.
expect(
document.detachedSymbols.some((symbol) => symbol.glyph === 'A_{fp}'),
).toBe(true);

const branchDocument = prepareExecutionDocument({
execution: capture('branch_compare.cso.py', 'compare'),
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down
Loading