diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e2f615..8a5a045 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,7 @@ jobs: python -m venv .venv .venv/bin/python -m pip wheel --no-deps ./packages/cso-python --wheel-dir artifacts .venv/bin/python -m pip install --no-index --find-links artifacts cs-object + .venv/bin/python -m pip install -r packages/cso-python/tests/requirements.txt echo "PYTHON=$GITHUB_WORKSPACE/.venv/bin/python" >> "$GITHUB_ENV" - run: npx playwright install --with-deps chromium - run: npm audit --audit-level=high diff --git a/docs/development.md b/docs/development.md index b00abf9..ecd4baa 100644 --- a/docs/development.md +++ b/docs/development.md @@ -89,6 +89,10 @@ Run package behavior tests for the changed project, then the affected root commands; package manifests define their local commands. `npm test`, `npm run typecheck` and `npm run lint` cover the workspace. +Before running Python tests locally, install their declared test tools with +`"$PYTHON" -m pip install -r packages/cso-python/tests/requirements.txt`. +`npm run test:python` includes real mypy consumers of generated bindings. CI, +isolation and installed-package suites install the same test-only requirements. `npm run test:isolation` builds/tests projects without root fixtures. `npm run test:packages` installs actual archives and a wheel into fresh external consumers. These installation checks need build prerequisites and dependency diff --git a/packages/cso-cli/README.md b/packages/cso-cli/README.md index 6e81118..99560c4 100644 --- a/packages/cso-cli/README.md +++ b/packages/cso-cli/README.md @@ -5,6 +5,13 @@ `cso bindings` generates typed calculation handles. `dev-export` and `dev-render` retain the unverified development paths. +`dev-render` is a math-only preview. It omits standalone text, figures, methods, +tables and unknown document items, and warns on stderr when these are present. +Assumptions and reference text therefore require `cso html` or `cso pdf` for a +complete report. The omission warning is separate from the command's unverified +numerical status; a successful development render establishes neither numerical +verification nor full document retention. + ## Run Follow [setup](../../docs/development.md#setup). In the workspace use diff --git a/packages/cso-cli/src/arguments.ts b/packages/cso-cli/src/arguments.ts index 377c824..c835bdc 100644 --- a/packages/cso-cli/src/arguments.ts +++ b/packages/cso-cli/src/arguments.ts @@ -1,5 +1,5 @@ -import { PythonIdentifierSchema } from '@cs-object/core'; import { resolve } from 'node:path'; +import { PythonIdentifierSchema } from '@cs-object/core'; export class UsageError extends Error {} @@ -168,7 +168,7 @@ Commands: dev Serve a verified calculation, editable inputs and report on localhost. bindings Generate typed calculation handles; --check reports stale files without writing. dev-export Export CSO JSON using the selected installed Python module. - dev-render Export once and render a development PDF with installed React/CSS. + dev-render Render an unverified math-only PDF; warn about omitted document content. verify Execute once and verify formulas; use cso verify --help. html Verify once and publish standalone HTML; --check-layout adds browser checks. pdf Verify once, retain evidence unless --no-evidence, and atomically publish a PDF. diff --git a/packages/cso-cli/src/development.ts b/packages/cso-cli/src/development.ts index 560cdc5..5e47ec1 100644 --- a/packages/cso-cli/src/development.ts +++ b/packages/cso-cli/src/development.ts @@ -1,29 +1,30 @@ -import { stringifyJson } from './json.ts'; +import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, - writeFileSync, renameSync, rmSync, + writeFileSync, } from 'node:fs'; -import { randomUUID } from 'node:crypto'; import { createRequire } from 'node:module'; -import { dirname, join, basename } from 'node:path'; -import { chromium } from 'playwright'; -import * as React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { FormulaSheet } from '@cs-object/react'; +import { basename, dirname, join } from 'node:path'; import { + type CalculationSourceObject, CalculationSourceObjectSchema, createSheetFromCalculationSourceObject, - type CalculationSourceObject, type SheetDocument, } from '@cs-object/core'; -import { UsageError, type DevelopmentOptions } from './arguments.ts'; +import { FormulaSheet } from '@cs-object/react'; +import { chromium } from 'playwright'; +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { type DevelopmentOptions, UsageError } from './arguments.ts'; +import { stringifyJson } from './json.ts'; import { runPythonProcess } from './python-process.ts'; -export { isolatedEnvironment } from './python-process.ts'; + export { parseCliArgs } from './arguments.ts'; +export { isolatedEnvironment } from './python-process.ts'; export type AnnotatedPythonPdfOptions = DevelopmentOptions; export interface PythonExporterOptions { readonly functionName: string; @@ -168,6 +169,28 @@ export const renderAnnotatedPythonPdf = async ( options: AnnotatedPythonPdfOptions, ): Promise => { const source = runPythonExporter(options); + const omissions = new Map(); + for (const item of [ + ...source.sections.flatMap((section) => section.items), + ...(source.detachedItems ?? []), + ]) { + if ( + item.kind !== 'symbol' && + item.kind !== 'symbolRef' && + item.kind !== 'section' + ) { + omissions.set(item.kind, (omissions.get(item.kind) ?? 0) + 1); + } + } + if (omissions.size > 0) { + const counts = [...omissions] + .map(([kind, count]) => `${kind}: ${count}`) + .join(', '); + process.stderr.write( + `Warning: dev-render is a math-only preview and omits authored document content (${counts}). ` + + 'Use cso html or cso pdf to retain document content. Development output remains numerically unverified.\n', + ); + } const calculation = createSheetFromCalculationSourceObject(source, { id: source.source.id, label: source.title, diff --git a/packages/cso-python/README.md b/packages/cso-python/README.md index e0e1904..00af04d 100644 --- a/packages/cso-python/README.md +++ b/packages/cso-python/README.md @@ -107,7 +107,10 @@ Use full captured execution or prepared HTML/PDF instead. [execution](src/cso_python/execution.py) and [bindings](src/cso_python/bindings.py) implement the pipeline described in [the code map](../../docs/code-map.md). -With the wheel installed, run `python -I tests/run.py` from this package directory. +With the wheel installed, run `python -m pip install -r tests/requirements.txt` +and `python -I tests/run.py` from this package directory. The test-only mypy pin +checks positive and negative consumers of freshly generated bindings, including +keyword arguments and typed public result keys. Runtime dependencies stay empty. The tests use package-owned synthetic fixtures and temporary bindings; no root examples or Node are required. [Workspace integration](../../tests/integration/README.md) owns actual example, installed cross-package and PDF acceptance. diff --git a/packages/cso-python/src/cso_python/bindings.py b/packages/cso-python/src/cso_python/bindings.py index eb727b9..b02d79b 100644 --- a/packages/cso-python/src/cso_python/bindings.py +++ b/packages/cso-python/src/cso_python/bindings.py @@ -27,10 +27,11 @@ def generate(directory: Path, *, check: bool = False) -> dict: "INVALID_DEPENDENCY_PATH", "Generated bindings cannot contain symbolic links", ) - files: dict[Path, bytes] = {output / "__init__.py": b"", output / "py.typed": b""} + modules: dict[Path, tuple[str, ...]] = {} + packages: dict[tuple[str, ...], Path] = {} for source in sources: relative = source.relative_to(root) - parts = [*relative.parts[:-1], relative.name[:-7]] + parts = (*relative.parts[:-1], relative.name[:-7]) if parts[0] == "_external": raise SourceError( "BINDING_OUTPUT_CONFLICT", @@ -46,6 +47,21 @@ def generate(directory: Path, *, check: bool = False) -> dict: "INVALID_BINDING_NAME", f"Binding module needs identifier path components: {relative}", ) + modules[source] = parts + for length in range(1, len(parts)): + packages.setdefault(parts[:length], source) + # Python prefers a package over a module with the same import name. Check + # the complete plan before capturing definitions or touching existing files. + for source, parts in modules.items(): + if parts in packages: + child = packages[parts].relative_to(root) + raise SourceError( + "BINDING_OUTPUT_CONFLICT", + f"Binding module {source.relative_to(root)} conflicts with " + f"package {'/'.join(parts)}/ required by {child}", + ) + files: dict[Path, bytes] = {output / "__init__.py": b"", output / "py.typed": b""} + for source, parts in modules.items(): capture = Capture(source) capture.root = root capture.for_bindings = True diff --git a/packages/cso-python/tests/requirements.txt b/packages/cso-python/tests/requirements.txt new file mode 100644 index 0000000..2ca3b12 --- /dev/null +++ b/packages/cso-python/tests/requirements.txt @@ -0,0 +1,2 @@ +# Test-only consumer verification. The runtime package has no dependencies. +mypy==1.18.2 diff --git a/packages/cso-python/tests/test_binding_types.py b/packages/cso-python/tests/test_binding_types.py new file mode 100644 index 0000000..0e56ccd --- /dev/null +++ b/packages/cso-python/tests/test_binding_types.py @@ -0,0 +1,104 @@ +"""Check generated public interfaces through real mypy consumers.""" + +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from cso_python.bindings import generate + + +class BindingTypesTest(unittest.TestCase): + def test_generated_consumers_and_any_mutation(self): + with tempfile.TemporaryDirectory(prefix="cso binding types ") as temporary: + root = Path(temporary) + source = '''from typing import Annotated +from cso_python import calculation, section, symbol + +@calculation(id="adjust", title="Adjust amount") +@section(id="adjust", title="Adjustment", root=True) +def calculate( + amount: Annotated[float, symbol(glyph="Q_{in}", description="Amount", unit="m")], + increment: Annotated[float, symbol(glyph="d_{inc}", description="Increment", unit="m")] = 3.0, +): + adjusted: Annotated[float, symbol(glyph="Q_{out}", description="Adjusted amount", unit="m")] = amount + increment + return {"adjusted": adjusted, "original": amount} +''' + (root / "sample.cso.py").write_text(source, encoding="utf-8") + (root / "nested").mkdir() + (root / "nested/count.cso.py").write_text( + source.replace("float", "int").replace("3.0", "3"), encoding="utf-8" + ) + generate(root) + consumers = { + "valid.py": '''from _cso_bindings.sample import calculate +from _cso_bindings.nested.count import calculate as count +result = calculate(amount=2.0) +adjusted: float = result["adjusted"] +original: float = result["original"] +calculate(amount=2, increment=3.0) +counted: int = count(amount=2)["adjusted"] +count(amount=2, increment=4) +# Python typing permits bool as an int subtype. CSO runtime rejects bool. +count(amount=True) +''', + "unknown_keyword.py": 'calculate(ammount=2.0)', + "missing_input.py": 'calculate()', + "wrong_input.py": 'calculate(amount="two")', + "positional.py": 'calculate(2.0)', + "private_output.py": 'calculate(amount=2.0)["private_intermediate"]', + "wrong_result.py": 'result: str = calculate(amount=2.0)["adjusted"]', + "float_to_int.py": 'from _cso_bindings.nested.count import calculate\ncalculate(amount=2.5)', + } + for name, content in consumers.items(): + if name not in ("valid.py", "float_to_int.py"): + content = "from _cso_bindings.sample import calculate\n" + content + (root / name).write_text(content + "\n", encoding="utf-8") + + def check(*names): + result = subprocess.run( + [ + sys.executable, "-I", "-m", "mypy", "--strict", + "--no-incremental", "--no-error-summary", + "--show-error-codes", "--no-pretty", *names, + ], + cwd=root, + capture_output=True, + text=True, + ) + # Missing checker/imports must never count as an expected failure. + self.assertEqual(result.stderr, "", result.stderr) + diagnostics = re.findall( + r"^([^:]+\.py):(\d+): error: .* \[([^\]]+)\]$", + result.stdout, + re.MULTILINE, + ) + return result, set(diagnostics) + + valid, diagnostics = check("valid.py") + self.assertEqual(valid.returncode, 0, valid.stdout) + self.assertEqual(diagnostics, set()) + expected = { + ("unknown_keyword.py", "2", "call-arg"), + ("missing_input.py", "2", "call-arg"), + ("wrong_input.py", "2", "arg-type"), + ("positional.py", "2", "misc"), + ("private_output.py", "2", "typeddict-item"), + ("wrong_result.py", "2", "assignment"), + ("float_to_int.py", "2", "arg-type"), + } + invalid_names = [name for name in consumers if name != "valid.py"] + invalid, diagnostics = check(*invalid_names) + self.assertEqual(invalid.returncode, 1, invalid.stdout) + self.assertEqual(diagnostics, expected, invalid.stdout) + + # Prove these consumers detect loss of the callable's type contract. + (root / "_cso_bindings/sample.pyi").write_text( + "from typing import Any\ncalculate: Any\n", encoding="utf-8" + ) + weakened, weakened_diagnostics = check(*invalid_names) + self.assertEqual(weakened.returncode, 1, weakened.stdout) + self.assertEqual(weakened_diagnostics, {("float_to_int.py", "2", "arg-type")}) + self.assertNotEqual(weakened_diagnostics, expected) diff --git a/packages/cso-python/tests/test_bindings.py b/packages/cso-python/tests/test_bindings.py index a6fb43e..1b5fced 100644 --- a/packages/cso-python/tests/test_bindings.py +++ b/packages/cso-python/tests/test_bindings.py @@ -1,6 +1,8 @@ """Generated binding bytes must remain portable and detect file drift.""" import runpy +import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -72,6 +74,67 @@ def test_non_utf8_authored_file_is_preserved(self): self.assertEqual(raised.exception.diagnostic["code"], "BINDING_OUTPUT_CONFLICT") self.assertEqual(path.read_bytes(), authored) + def test_module_package_collisions_fail_before_writing_in_both_modes(self): + source = self.root / "élément" / "café.cso.py" + content = source.read_bytes() + for module, child in ( + ("sample.cso.py", "sample/child.cso.py"), + ("group/sample.cso.py", "group/sample/child.cso.py"), + ): + with self.subTest(module=module): + calculation = self.root / module + nested = self.root / child + calculation.parent.mkdir(parents=True, exist_ok=True) + nested.parent.mkdir(parents=True, exist_ok=True) + calculation.write_bytes(content) + nested.write_bytes(content) + for check in (False, True): + with self.assertRaises(SourceError) as raised: + generate(self.root, check=check) + diagnostic = raised.exception.diagnostic + self.assertEqual(diagnostic["code"], "BINDING_OUTPUT_CONFLICT") + self.assertIn(str(Path(module)), diagnostic["message"]) + self.assertIn(str(Path(child)), diagnostic["message"]) + self.assertFalse((self.root / "_cso_bindings").exists()) + calculation.unlink() + nested.unlink() + + def test_collision_preserves_valid_output_and_valid_imports(self): + source = self.root / "élément" / "café.cso.py" + flat = self.root / "sample.cso.py" + flat.write_bytes(source.read_bytes()) + generate(self.root) + output = self.root / "_cso_bindings" + saved = { + p.relative_to(output): p.read_bytes() + for p in output.rglob("*") if p.is_file() + } + # Resolve through Python's import machinery, rather than reading stub text. + result = subprocess.run( + [ + sys.executable, "-c", + "from _cso_bindings.sample import calculate; " + "from _cso_bindings.élément.café import calculate as nested; " + "assert calculate(width=3) == nested(width=3) == {'café': 9}", + ], + cwd=self.root, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + nested = self.root / "sample" / "child.cso.py" + nested.parent.mkdir() + nested.write_bytes(source.read_bytes()) + for check in (False, True): + with self.assertRaises(SourceError): + generate(self.root, check=check) + current = { + p.relative_to(output): p.read_bytes() + for p in output.rglob("*") + if p.is_file() and "__pycache__" not in p.parts + } + self.assertEqual(current, saved) + if __name__ == "__main__": unittest.main() diff --git a/packages/cso-react/src/ascii-math/NotationMathmlView.tsx b/packages/cso-react/src/ascii-math/NotationMathmlView.tsx index 11ef52d..f7d800b 100644 --- a/packages/cso-react/src/ascii-math/NotationMathmlView.tsx +++ b/packages/cso-react/src/ascii-math/NotationMathmlView.tsx @@ -1,8 +1,7 @@ -import { assertNever } from '@cs-object/core'; import type { NotationExpression, NotationNode } from '@cs-object/core'; +import { assertNever } from '@cs-object/core'; import type { ReactElement } from 'react'; import type { MathMathMLAttributes } from '../mathml/attributes.ts'; -import { MATHML_FONT_SIZE } from '../mathml/constants.ts'; interface NotationRenderOptions { readonly identifierMathVariant?: 'normal'; @@ -116,12 +115,8 @@ const NotationNodeMathmlView = ({ case 'fraction': return ( - - {renderNode(node.numerator)} - - - {renderNode(node.denominator)} - + {renderNode(node.numerator)} + {renderNode(node.denominator)} ); case 'subscript': diff --git a/packages/cso-react/src/mathml/function-renderers.tsx b/packages/cso-react/src/mathml/function-renderers.tsx index 0777cf9..51cc4d3 100644 --- a/packages/cso-react/src/mathml/function-renderers.tsx +++ b/packages/cso-react/src/mathml/function-renderers.tsx @@ -7,7 +7,6 @@ */ import type { ReactElement, ReactNode } from 'react'; -import { MATHML_FONT_SIZE } from './constants.ts'; interface SpecialValueFunctionRenderProps { readonly functionId: string; @@ -20,8 +19,8 @@ interface ConditionalFunctionMathmlProps { readonly argFalse: ReactNode; } -const baseStyle = { fontSize: MATHML_FONT_SIZE, textAlign: 'left' as const }; -const fontSizeStyle = { fontSize: MATHML_FONT_SIZE }; +const baseStyle = { textAlign: 'left' as const }; +const branchStyle = { ...baseStyle, paddingBlock: '0.4em' }; const ConditionalFunctionMathml = ({ argCnd, @@ -30,20 +29,22 @@ const ConditionalFunctionMathml = ({ }: ConditionalFunctionMathmlProps): ReactElement => { return ( - {'{'} + {'{'} - - {argTrue} - - {'if '} + + {argTrue} + + {'if '} {argCnd} - - {argFalse} - - otherwise. + + + {argFalse} + + + otherwise. @@ -60,8 +61,8 @@ const FractionFunctionMathml = ({ }): ReactElement => { return ( - {numerator} - {denominator} + {numerator} + {denominator} ); }; @@ -75,8 +76,8 @@ const PowerFunctionMathml = ({ }): ReactElement => { return ( - {base} - {exponent} + {base} + {exponent} ); }; @@ -88,9 +89,7 @@ const SquareRootFunctionMathml = ({ }): ReactElement => { return ( - - {value} - + {value} ); }; @@ -101,7 +100,7 @@ const AbsoluteValueFunctionMathml = ({ readonly value: ReactNode; }): ReactElement => { return ( - + | @@ -119,8 +118,8 @@ const UnaryMinusFunctionMathml = ({ readonly value: ReactNode; }): ReactElement => { return ( - - {'-'} + + {'-'} {value} ); diff --git a/packages/cso-react/src/mathml/symbol-value/components.tsx b/packages/cso-react/src/mathml/symbol-value/components.tsx index a08f001..01a59c1 100644 --- a/packages/cso-react/src/mathml/symbol-value/components.tsx +++ b/packages/cso-react/src/mathml/symbol-value/components.tsx @@ -11,7 +11,6 @@ import { assertNever, formatNumerical } from '@cs-object/core'; import { isValidElement, type ReactElement, type ReactNode } from 'react'; import { AsciiMathView } from '../../ascii-math/AsciiMathView.tsx'; import type { MathMLGlobalAttributes } from '../attributes.ts'; -import { MATHML_FONT_SIZE } from '../constants.ts'; interface SymbolValueBinaryOperatorMathmlViewProps { readonly operator: SheetBinaryOperator; @@ -89,19 +88,14 @@ export const SymbolValueDraftMathmlView = ({ draft, position, }: SymbolValueDraftMathmlViewProps): ReactElement => { - const textStyle = { fontSize: MATHML_FONT_SIZE }; return position !== undefined && isDraftPositionValid({ draft, position }) ? ( - - {position > 0 && ( - {draft.substring(0, position)} - )} - {position < draft.length && ( - {draft.substring(position)} - )} + + {position > 0 && {draft.substring(0, position)}} + {position < draft.length && {draft.substring(position)}} ) : ( - - {draft} + + {draft} ); }; @@ -109,19 +103,16 @@ export const SymbolValueDraftMathmlView = ({ export const SymbolValueLiteralMathmlView = ({ literal, }: SymbolValueLiteralMathmlViewProps): ReactElement => { - const mathProps: MathMLGlobalAttributes = { - style: { fontSize: MATHML_FONT_SIZE }, - }; switch (literal?.kind) { case undefined: case 'empty': - return {'▢'}; + return {'▢'}; case 'boolean': - return {literal.value ? 'true' : 'false'}; + return {literal.value ? 'true' : 'false'}; case 'number': - return {formatNumerical(literal.value)}; + return {formatNumerical(literal.value)}; case 'string': - return {literal.value}; + return {literal.value}; default: return assertNever(literal); } diff --git a/packages/cso-react/src/styles.css b/packages/cso-react/src/styles.css index 21090c5..21fbc07 100644 --- a/packages/cso-react/src/styles.css +++ b/packages/cso-react/src/styles.css @@ -52,6 +52,14 @@ math { "Times New Roman", serif; } +/* Size scripts relative to their parent without shrinking nested notation + below 8px. CSS transforms move ink without updating MathML layout bounds. */ +[data-formula-sheet] :is(msup, msub, msubsup) > :nth-child(2), +[data-formula-sheet] msubsup > :nth-child(3), +[data-formula-sheet] mfrac > * { + font-size: max(0.71em, 8px); +} + [data-formula-sheet-print-root] { display: none; } diff --git a/scripts/check-project-isolation.ts b/scripts/check-project-isolation.ts index 743c0ed..856303d 100644 --- a/scripts/check-project-isolation.ts +++ b/scripts/check-project-isolation.ts @@ -1,19 +1,19 @@ -import { z } from 'zod'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; /** Build/test disposable copies with no workspace ancestors or example data. */ import { cpSync, mkdirSync, mkdtempSync, - readFileSync, readdirSync, + readFileSync, realpathSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; +import { z } from 'zod'; const repository = fileURLToPath(new URL('..', import.meta.url)); const output = realpathSync( @@ -151,6 +151,13 @@ run('python', directory, installedPython, [ '--no-deps', join(archives, wheel), ]); +run('python', directory, installedPython, [ + '-m', + 'pip', + 'install', + '-r', + 'tests/requirements.txt', +]); run('python', directory, installedPython, ['-I', 'tests/run.py']); const identity = realpathSync( run('python', directory, installedPython, [ diff --git a/tests/integration/installed/acceptance.ts b/tests/integration/installed/acceptance.ts index d5f90e1..5f6cb43 100644 --- a/tests/integration/installed/acceptance.ts +++ b/tests/integration/installed/acceptance.ts @@ -3,13 +3,13 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { copyFileSync, - existsSync, cpSync, + existsSync, lstatSync, mkdirSync, mkdtempSync, - readFileSync, readdirSync, + readFileSync, realpathSync, writeFileSync, } from 'node:fs'; @@ -107,7 +107,11 @@ function consumer(name: string, dependencies: string[]): string { } for (const name of ['core', 'react', 'cli']) { const directoryPath = join(directory, 'node_modules/@cs-object', name); - if (dependencies.some((dependency) => dependency.includes(`cs-object-${name}-`))) + if ( + dependencies.some((dependency) => + dependency.includes(`cs-object-${name}-`), + ) + ) assert(!lstatSync(directoryPath).isSymbolicLink()); } return directory; @@ -168,9 +172,7 @@ try { `Unexpected package content in ${archive}`, ); assert( - !files.some((path) => - /(?:apps\/demo|node_modules|\.cso\.py)/.test(path), - ), + !files.some((path) => /(?:apps\/demo|node_modules|\.cso\.py)/.test(path)), ); } const builder = join(root, 'wheel builder'); @@ -228,6 +230,13 @@ try { join(copiedRepo, 'packages/cso-python/tests'), { recursive: true, filter: (path) => !path.includes('__pycache__') }, ); + run(pythonOnly, python, [ + '-m', + 'pip', + 'install', + '-r', + join(copiedRepo, 'packages/cso-python/tests/requirements.txt'), + ]); const testOutput = run(pythonOnly, python, [ join(copiedRepo, 'packages/cso-python/tests/run.py'), ]); @@ -401,6 +410,60 @@ try { '--input', 'height=3', ]; + // Exercise the installed CLI's permitted warning-based math-only preview. + const proseSource = `from typing import Annotated +from cso_python import calculation, section, symbol, text + +@calculation(id="rectangle-prose", title="Rectangle with assumptions") +@section(id="rectangle", title="Rectangle", root=True) +def rectangle( + width: Annotated[float, symbol(glyph="w_{rect}", description="Width", unit="m")], + height: Annotated[float, symbol(glyph="h_{rect}", description="Height", unit="m")], +): + text(id="assumptions", content="Assume perpendicular rectangle sides.") + text(id="reference", content="Reference: area equals width multiplied by height.") + area: Annotated[float, symbol(glyph="A_{rect}", description="Area", unit="m^2")] = width * height + return {"area": area} +`; + writeFileSync(join(full, 'prose.cso.py'), proseSource); + const previewArgs = [ + entry, + 'dev-render', + '--source', + 'prose.cso.py', + '--function', + functionName, + '--input', + 'width=4', + '--input', + 'height=3', + '--out', + 'output/prose.pdf', + ]; + const preview = spawnSync(process.execPath, previewArgs, { + cwd: full, + env: environment, + encoding: 'utf8', + }); + commands.push({ + cwd: full, + command: process.execPath, + args: previewArgs, + status: preview.status, + }); + writeFileSync( + join(root, 'development-content-warning.log'), + `${preview.stdout}\n${preview.stderr}`, + ); + if (preview.error) throw preview.error; + assert.equal(preview.status, 0, preview.stderr); + assert.match( + preview.stderr, + /math-only preview.*omits authored document content \(text: 2\)/, + ); + assert.match(preview.stderr, /cso html or cso pdf/); + assert.match(preview.stderr, /numerically unverified/); + assert(existsSync(join(full, 'output/prose.pdf'))); for (const width of [2, 4]) { run(full, process.execPath, [ entry, diff --git a/tests/integration/mathml-spacing-browser.test.ts b/tests/integration/mathml-spacing-browser.test.ts new file mode 100644 index 0000000..67c1b22 --- /dev/null +++ b/tests/integration/mathml-spacing-browser.test.ts @@ -0,0 +1,234 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import type { + SheetDocument, + SheetSymbol, + SheetValueNode, +} from '@cs-object/core'; +import { FormulaSheet } from '@cs-object/react'; +import { chromium } from 'playwright'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, test } from 'vitest'; + +type Expression = + | number + | string + | { + readonly operation: string; + readonly args: readonly Expression[]; + }; +const operation = (id: string, ...args: Expression[]): Expression => ({ + operation: `fg.${id}`, + args, +}); +const condition = operation('gt', 'area', 'width'); +const formulas = { + simple: operation('cnd', condition, 'area', 'width'), + tall: operation( + 'cnd', + condition, + operation('divide', operation('pow', 'area', 2), 'width'), + operation('sqrt', 'factor'), + ), + nested: operation( + 'cnd', + condition, + operation('cnd', condition, 'area', 'width'), + 'factor', + ), + fractional: operation( + 'pow', + operation('add', 'area', 'width'), + operation('divide', 1, operation('add', 'factor', 2)), + ), + powers: operation('pow', 'area', operation('pow', 'width', 'factor')), + square: operation('pow', 'area', 2), +}; + +const valueTree = (expression: Expression): SheetSymbol['valueTree'] => { + const nodes: SheetValueNode[] = []; + let nextKey = 0; + const lower = (expression: Expression): string => { + const key = `node-${nextKey++}`; + if (typeof expression === 'number') { + nodes.push({ + kind: 'literal', + key, + value: { kind: 'number', value: expression }, + }); + } else if (typeof expression === 'string') { + nodes.push({ kind: 'symbol', key, symbolId: expression }); + } else { + const argKeys = expression.args.map(lower); + nodes.push({ + kind: 'function', + key, + functionId: expression.operation, + argKeys, + }); + } + return key; + }; + return { + rootKey: lower(expression), + nodes, + result: { + kind: 'number', + value: typeof expression === 'number' ? expression : 0, + }, + }; +}; + +// Supplied results are placeholders. This fixture verifies layout, not arithmetic. +const inputSymbols: SheetSymbol[] = [ + { + id: 'area', + glyph: 'A_{eff}', + description: 'Area', + unit: '', + valueTree: valueTree(1234.56), + }, + { + id: 'width', + glyph: 'b_{web}', + description: 'Width', + unit: '', + valueTree: valueTree(78.9), + }, + { + id: 'factor', + glyph: 'gamma_{M0}', + description: 'Factor', + unit: '', + valueTree: valueTree(1.25), + }, +]; +const require = createRequire(import.meta.url); +const css = readFileSync(require.resolve('@cs-object/react/style.css'), 'utf8'); +const markup = Object.entries(formulas) + .map(([id, expression]) => { + const sheet: SheetDocument = { + id, + title: `Synthetic ${id} layout probe`, + rootSectionId: 'root', + sections: [{ id: 'root', title: id, items: [{ kind: 'symbol', id }] }], + symbols: [ + ...inputSymbols, + { + id, + glyph: `Q_{${id === 'square' ? 'sqr' : id}}`, + description: id, + unit: '', + valueTree: valueTree(expression), + }, + ], + }; + return `
${renderToStaticMarkup(createElement(FormulaSheet, { sheet }))}
`; + }) + .join(''); + +test('conditional branches and power scripts stay legible in screen and print', async () => { + const browser = await chromium.launch(); + try { + const page = await browser.newPage(); + await page.setContent(`${markup}`); + for (const media of ['screen', 'print'] as const) { + await page.emulateMedia({ media }); + await page.evaluate(() => document.fonts.ready); + const branches = await page.locator('mtable').evaluateAll((tables) => + tables.map((table) => { + const rows = [...table.querySelectorAll(':scope > mtr')]; + const contentBounds = (row: Element) => + [...row.children].map((cell) => { + const range = document.createRange(); + range.selectNodeContents(cell); + return range.getBoundingClientRect(); + }); + const first = rows[0]; + const second = rows[1]; + if (!first || !second) throw new Error('Missing conditional branch'); + const firstBounds = contentBounds(first); + const secondBounds = contentBounds(second); + const value = firstBounds[0]; + const label = firstBounds[1]; + if (!value || !label) + throw new Error('Missing branch value or label'); + return { + formula: table.closest('[data-probe]')?.getAttribute('data-probe'), + vertical: + Math.min(...secondBounds.map((b) => b.top)) - + Math.max(...firstBounds.map((b) => b.bottom)), + horizontal: label.left - value.right, + }; + }), + ); + expect(branches.length).toBeGreaterThan(0); + for (const gap of branches) { + expect( + gap.vertical, + `${media} ${gap.formula} branch gap`, + ).toBeGreaterThanOrEqual(4); + expect(gap.vertical, `${media} excessive branch gap`).toBeLessThan(16); + expect( + gap.horizontal, + `${media} value/condition gap`, + ).toBeGreaterThanOrEqual(7); + } + const scripts = await page.locator('msup').evaluateAll((powers) => + powers.map((power) => { + const base = power.children[0]; + const exponent = power.children[1]; + if (!base || !exponent) throw new Error('Missing power operand'); + const baseBounds = base.getBoundingClientRect(); + const exponentBounds = exponent.getBoundingClientRect(); + return { + gap: exponentBounds.left - baseBounds.right, + raised: baseBounds.bottom - exponentBounds.bottom, + baseSize: Number.parseFloat(getComputedStyle(base).fontSize), + exponentSize: Number.parseFloat( + getComputedStyle(exponent).fontSize, + ), + leaves: [...exponent.querySelectorAll('mi, mn, mtext')].map( + (leaf) => Number.parseFloat(getComputedStyle(leaf).fontSize), + ), + }; + }), + ); + for (const script of scripts) { + expect(script.gap, `${media} detached exponent`).toBeLessThan(3); + expect( + script.gap, + `${media} colliding exponent`, + ).toBeGreaterThanOrEqual(-0.5); + expect(script.raised, `${media} exponent position`).toBeGreaterThan(2); + expect(script.exponentSize, `${media} script size`).toBeLessThan( + script.baseSize, + ); + for (const size of script.leaves) { + expect(size, `${media} exponent leaf size`).toBeLessThanOrEqual( + script.exponentSize, + ); + expect(size, `${media} exponent legibility`).toBeGreaterThanOrEqual( + 7, + ); + } + } + // Compound bases and fractional/nested exponents retain their grouping. + expect( + await page.locator('[data-probe="fractional"] msup mfrac').count(), + ).toBeGreaterThan(0); + expect( + await page.locator('[data-probe="powers"] msup msup').count(), + ).toBeGreaterThan(0); + expect( + await page + .locator('[data-probe="fractional"] msup > :first-child') + .first() + .textContent(), + ).toMatch(/\(.*\)/); + } + } finally { + await browser.close(); + } +}); diff --git a/tests/integration/method-table.test.ts b/tests/integration/method-table.test.ts index 72ce654..6023900 100644 --- a/tests/integration/method-table.test.ts +++ b/tests/integration/method-table.test.ts @@ -1014,7 +1014,17 @@ test('publishes verified conditional notes without rerunning dormant branches', expect(pdf.status, pdf.stdout || pdf.stderr).toBe(0); const info = spawnSync('pdfinfo', [pdfPath], { encoding: 'utf8' }); expect(info.status, info.stderr).toBe(0); - expect(info.stdout).toContain('Pages: 1'); + // Readable branch spacing may continue the final result onto a second page. + expect(info.stdout).toMatch(/Pages:\s+[12]\b/); + const extracted = spawnSync('pdftotext', ['-layout', pdfPath, '-'], { + encoding: 'utf8', + }); + expect(extracted.status, extracted.stderr).toBe(0); + const pages = extracted.stdout.split('\f').filter((page) => page.trim()); + expect(pages.length).toBe(Number(info.stdout.match(/Pages:\s+(\d+)/)?.[1])); + for (const content of ['Chosen', 'Guarded', 'Narrow', 'Wide', 'Compared']) { + expect(extracted.stdout).toContain(content); + } }, 180_000); test('keeps a failed engineering check beside the unchanged number', async () => {