diff --git a/.gitattributes b/.gitattributes index fcadb2cf97..7ade3d6750 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ * text eol=lf + +# Generated by `npm run ci-update`; see nanvm-lib/tests/README.md. +nanvm-lib/tests/test/generated.rs linguist-generated=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a755bb648..1a9f327f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ history. ## Unreleased +- `nanvm-lib` operator behaviour is described once, as data, in + `fjs/nanvm/module.f.mjs`: the JavaScript proof and the generated + Rust tests both read it, so a case is written once instead of twice + [#1489](https://github.com/functionalscript/functionalscript/pull/1489) - **BREAKING CHANGES:** `fjs/bnf/proof` migrates from authored TypeScript (`.f.ts`) to JSDoc-typed JavaScript (`.f.mjs`) — no local types to split. Importers must use the `.f.mjs` specifier diff --git a/fjs/ci/README.md b/fjs/ci/README.md index 20efacbc6c..0d76c91e39 100644 --- a/fjs/ci/README.md +++ b/fjs/ci/README.md @@ -95,9 +95,14 @@ and `ci-update`. A typical FunctionalScript project can define them like this: } ``` -`ci-update` must regenerate every generated file the project keeps in Git — -today `.github/workflows/ci.yml`, with more (e.g. generated Rust sources) -planned. The Node 26 job runs it right after `npm ci` and fails via +`ci-update` must regenerate every generated file the project keeps in Git, not +only the workflow. `fjs ci` covers `.github/workflows/ci.yml` and the generated +Nix flakes; a project with other generators chains them into the same script, as +this repository does for `nanvm-lib/tests/test/generated.rs` (see +[`fjs/nanvm/README.md`](../nanvm/README.md)). Everything chained there is +covered by the drift check below for free. + +The Node 26 job runs it right after `npm ci` and fails via `git add -A && git diff --cached --exit-code` when the committed tree no longer matches the generator's output, so forgetting to regenerate after changing a generator breaks the build instead of silently using stale files. Staging with diff --git a/fjs/media/rust/module.f.mjs b/fjs/media/rust/module.f.mjs new file mode 100644 index 0000000000..29ccf81910 --- /dev/null +++ b/fjs/media/rust/module.f.mjs @@ -0,0 +1,93 @@ +/** + * Rust source literals. + * + * Printing a value as Rust source is not specific to any one generator: the + * MVP roadmap's `fjs compile .rs` backend and `nanvm-lib`'s generated + * operator tests both need the same escaping and the same spelling of the + * numeric edge cases. This module owns that layer — the syntax of a literal — + * and nothing above it. Expressions, items, and whatever API a generator + * targets stay with the generator. + * + * The sibling of `fjs/media/nix`, which does the same for Nix expressions. + * + * @module + * + * @example + * + * ```js + * import { f64Literal, i64Literal, stringLiteral } from './module.f.mjs' + * + * stringLiteral('a"b') // '"a\\"b"' + * f64Literal(-0) // '-0f64' + * i64Literal(-456n) // '-456' + * ``` + */ + +/** + * A double-quoted Rust string literal. + * + * Any other control character is rejected rather than escaped: no caller needs + * one, and a silently mangled literal is worse than a failed generation. + * + * @type {(v: string) => string} + */ +export const stringLiteral = v => `"${[...v].map(c => { + switch (c) { + case '\\': { return '\\\\' } + case '"': { return '\\"' } + case '\n': { return '\\n' } + case '\r': { return '\\r' } + case '\t': { return '\\t' } + default: { + if (c < ' ' || c === '\u007f') { + throw ['control character in a Rust string literal', v] + } + return c + } + } +}).join('')}"` + +/** + * An `f64` literal. + * + * `toString` already prints the shortest round-tripping decimal and Rust + * parses decimal float literals the same way JavaScript does, so the digits + * carry over unchanged. Only the three non-finite values and `-0` — which + * `toString` prints as `0` — need spelling out. The `f64` suffix keeps whole + * numbers from lexing as integers. + * + * @type {(v: number) => string} + */ +export const f64Literal = v => { + if (Number.isNaN(v)) { return 'f64::NAN' } + if (v === Infinity) { return 'f64::INFINITY' } + if (v === -Infinity) { return 'f64::NEG_INFINITY' } + return `${Object.is(v, -0) ? '-0' : v.toString()}f64` +} + +const i64Min = -(2n ** 63n) +const i64Max = 2n ** 63n - 1n + +/** + * An `i64` literal. Throws for a value the type cannot hold, rather than + * silently truncating it. + * + * @type {(v: bigint) => string} + */ +export const i64Literal = v => { + if (v < i64Min || v > i64Max) { throw ['bigint out of i64 range', v] } + return v.toString() +} + +/** + * A `snake_case` Rust identifier from a `camelCase` name. + * + * Only the casing is converted: a name that is not already a valid identifier + * stays invalid, so callers pass names they control. + * + * @type {(v: string) => string} + */ +export const snakeCase = v => [...v].map(c => { + const lower = c.toLowerCase() + return c === lower ? c : `_${lower}` +}).join('') diff --git a/fjs/media/rust/proof.f.mjs b/fjs/media/rust/proof.f.mjs new file mode 100644 index 0000000000..32b6cfe167 --- /dev/null +++ b/fjs/media/rust/proof.f.mjs @@ -0,0 +1,50 @@ +/** + * Proofs for Rust source literals. + * + * @module + */ + +import { assertEq } from '../../asserts/module.f.mjs' +import { f64Literal, i64Literal, snakeCase, stringLiteral } from './module.f.mjs' + +export const proof = { + stringLiteral: () => { + assertEq(stringLiteral(''), '""') + assertEq(stringLiteral('abc'), '"abc"') + assertEq(stringLiteral('a\\b'), '"a\\\\b"') + assertEq(stringLiteral('a"b'), '"a\\"b"') + assertEq(stringLiteral('a\nb'), '"a\\nb"') + assertEq(stringLiteral('a\rb'), '"a\\rb"') + assertEq(stringLiteral('a\tb'), '"a\\tb"') + // Non-ASCII needs no escape: Rust source is UTF-8. + assertEq(stringLiteral('é'), '"é"') + }, + f64Literal: () => { + assertEq(f64Literal(NaN), 'f64::NAN') + assertEq(f64Literal(Infinity), 'f64::INFINITY') + assertEq(f64Literal(-Infinity), 'f64::NEG_INFINITY') + assertEq(f64Literal(-0), '-0f64') + assertEq(f64Literal(0), '0f64') + assertEq(f64Literal(2.3), '2.3f64') + assertEq(f64Literal(-239), '-239f64') + // Rust's exponent accepts a `+`, which is how `toString` prints it. + assertEq(f64Literal(1e21), '1e+21f64') + }, + i64Literal: () => { + assertEq(i64Literal(0n), '0') + assertEq(i64Literal(-456n), '-456') + assertEq(i64Literal(-(2n ** 63n)), '-9223372036854775808') + assertEq(i64Literal(2n ** 63n - 1n), '9223372036854775807') + }, + snakeCase: () => { + assertEq(snakeCase('emptyArray'), 'empty_array') + assertEq(snakeCase('stringCoercion'), 'string_coercion') + assertEq(snakeCase('eq'), 'eq') + }, + throw: { + i64TooLarge: () => i64Literal(2n ** 63n), + i64TooSmall: () => i64Literal(-(2n ** 63n) - 1n), + controlCharacter: () => stringLiteral('a\u0000b'), + deleteCharacter: () => stringLiteral('a\u007fb'), + }, +} diff --git a/fjs/nanvm/README.md b/fjs/nanvm/README.md new file mode 100644 index 0000000000..a5b4575105 --- /dev/null +++ b/fjs/nanvm/README.md @@ -0,0 +1,91 @@ +# The FunctionalScript side of NaNVM + +`nanvm-lib/` is the Rust crate. This directory is the FunctionalScript that +targets it — today the shared operator test data and the printer that turns it +into Rust tests; later the `.rs` output branch of `fjs compile` (see +[`nanvm-lib/todo/mvp-roadmap.md`](../../nanvm-lib/todo/mvp-roadmap.md)). + +Operator behaviour is described **once**, as data, and checked twice: against a +standard JavaScript engine and against `nanvm-lib`. Adding an operator or a case +means editing one file. + +```text +module.f.mjs ──> proof.f.mjs ──────────────────────────────────> a JS engine + (data) └─> rust/module.f.mjs ──> nanvm-lib/tests/test/generated.rs ──> nanvm-lib + (printer) (generated) +``` + +## Files + +| File | Role | +|---|---| +| [`types.ts`](types.ts) | The shape of the data: `Value`, `Case`, `Group`, `Eq`, `Data`. | +| [`module.f.mjs`](module.f.mjs) | **The single source of truth** — every operator case, as data. | +| [`proof.f.mjs`](proof.f.mjs) | Runs each case through the native JavaScript operators. | +| [`rust/module.f.mjs`](rust/module.f.mjs) | Prints the data as Rust, against the `nanvm-lib` API. | +| [`update/module.f.mjs`](update/module.f.mjs) | Writes the printer's output. Run by `npm run ci-update`. | + +Rust *literal* syntax — string escaping, `f64`/`i64` spelling, `snake_case` +identifiers — is not specific to this generator and lives in +[`fjs/media/rust`](../media/rust/module.f.mjs). + +## Writing a case + +Operands and expectations are ordinary JavaScript values, following +[`fjs/types/rtti`](../types/rtti/README.md)'s convention that a constant is its +own description: + +```js +{ name: 'arrayNumber', args: [[2.3]], expected: 2.3 }, +{ name: 'emptyObjectByOne', args: [{}, 1], expected: NaN }, +{ name: 'bigint', args: [0n], expected: throws }, +``` + +Three things a literal cannot express are written as thunks — a function in the +data is always a *description*, never a value that happens to be a function: + +| Thunk | Means | +|---|---| +| `functionValue` | a function value (no operator here inspects which one) | +| `ref(name)` | one of the `eq` `shared` values, so the *same* object reaches both sides | +| `throws` | the case must throw; valid only as `expected` | + +`expected` is compared with `Object.is`, so `NaN` matches `NaN` and `0` does not +match `-0`. The Rust side compares the same way. + +## The loop + +1. Add the case to `data` in [`module.f.mjs`](module.f.mjs). +2. `npm test` — the JavaScript proof now covers it, which is what makes the + expectation authoritative: it is JavaScript's answer, not a guess. +3. `npm run ci-update` to regenerate, then `cargo test`. +4. If `nanvm-lib` does not implement it yet, give the case a `rust` reason. The + generated file keeps it as a commented-out `TODO`, and the JavaScript proof + keeps running it. + +Never edit `nanvm-lib/tests/test/generated.rs`: CI regenerates it on every pull +request and fails if the committed copy differs (see +[`fjs/ci/README.md`](../ci/README.md)). + +## What is not shared + +Two kinds of test stay hand-written, because there is nothing on the other side +to compare them with. + +**JavaScript only** — [`proof.f.mjs`](proof.f.mjs)'s `jsOnly` section: +`ToPrimitive` consulting an object's `toString` method, and a function's string +form (engine-specific source text). `nanvm-lib` has no object methods yet. + +**Rust only** — `nanvm-lib/tests/test/main.rs`: `try_into` out of `Any`, `Debug` +formatting, multi-limb bigint arithmetic, serialization round-trips, and the +exact text of `nanvm-lib`'s own error messages. These are properties of the VM, +not of JavaScript. + +## Known divergence + +`String(123n)` is `"123"` in JavaScript and `"0x7Bn"` in `nanvm-lib` — see +[bigint-decimal-string-coercion](../../nanvm-lib/todo/bigint-decimal-string-coercion.md). +The two affected cases carry a `rust` reason, so the gap is recorded in the data +itself rather than in a coverage table. That is the point of the arrangement: a +divergence is a property of a case, and a table of them goes stale the moment +someone fixes one. diff --git a/fjs/nanvm/module.f.mjs b/fjs/nanvm/module.f.mjs new file mode 100644 index 0000000000..f04658ee92 --- /dev/null +++ b/fjs/nanvm/module.f.mjs @@ -0,0 +1,240 @@ +/** + * The single source of truth for `nanvm-lib` operator behaviour. + * + * This module is pure data: it names every operator case once, with the + * arguments and the expected result written as ordinary JavaScript values. + * Two consumers read it, so a new case is written once and checked twice: + * + * - [`proof.f.mjs`](./proof.f.mjs) runs each case through the native + * JavaScript operators, proving that the expectations describe JavaScript. + * - [`rust/module.f.mjs`](./rust/module.f.mjs) prints each case as Rust, + * producing `nanvm-lib/tests/test/generated.rs`, which runs the same case + * against `nanvm-lib`. + * + * Cases `nanvm-lib` does not implement yet carry a `rust` reason and are + * emitted as commented-out `TODO`s instead of being silently dropped — the + * gaps between the two implementations are part of the data. + * + * @module + * + * @import { Case, Data, Special, Value } from './types.ts' + * + * @example + * + * ```js + * import { data } from './module.f.mjs' + * + * data.groups.length // 4 + * ``` + */ + +/** + * A function value. + * + * Every operator here coerces a function through `ToPrimitive`, which never + * inspects it, so which function it is does not matter. + * + * @type {Special} + */ +export const functionValue = () => ['function'] + +/** + * The case must throw. Valid only as a case's `expected`. + * + * @type {Special} + */ +export const throws = () => ['throw'] + +/** + * One of the `eq` `shared` values, so the same object reaches both sides of a + * comparison. + * + * @type {(name: string) => Special} + */ +export const ref = name => () => ['ref', name] + +/** + * `+n` and `-n` share their whole argument space: both coerce with `ToNumber` + * and differ only in the sign of the result. Listing the arguments once keeps + * the two groups from drifting apart. + * + * @type {(negate: boolean) => readonly Case[]} + */ +const numberCoercionCases = negate => { + /** @type {(v: number) => number} */ + const result = v => negate ? -v : v + return [ + { name: 'null', args: [null], expected: result(0) }, + { name: 'undefined', args: [undefined], expected: NaN }, + { name: 'booleanFalse', args: [false], expected: result(0) }, + { name: 'booleanTrue', args: [true], expected: result(1) }, + { name: 'numberZero', args: [0], expected: result(0) }, + { name: 'numberPositive', args: [2.3], expected: result(2.3) }, + { name: 'numberNegative', args: [-2.3], expected: result(-2.3) }, + { name: 'numberLarge', args: [-239], expected: result(-239) }, + { name: 'numberInfinity', args: [Infinity], expected: result(Infinity) }, + { name: 'numberNegativeInfinity', args: [-Infinity], expected: result(-Infinity) }, + { name: 'numberNan', args: [NaN], expected: NaN }, + { name: 'stringEmpty', args: [''], expected: result(0) }, + { name: 'stringZero', args: ['0'], expected: result(0) }, + { name: 'stringNumber', args: ['2.3'], expected: result(2.3) }, + { name: 'stringExponent', args: ['2.3e2'], expected: result(230) }, + { name: 'stringNotANumber', args: ['a'], expected: NaN }, + { name: 'arrayEmpty', args: [[]], expected: result(0) }, + { name: 'arrayNumber', args: [[2.3]], expected: result(2.3) }, + { name: 'arrayNegativeNumber', args: [[-0.3]], expected: result(-0.3) }, + { name: 'arrayString', args: [['-2.3']], expected: result(-2.3) }, + { name: 'arrayPositiveString', args: [['0.3']], expected: result(0.3) }, + { name: 'arrayNull', args: [[null]], expected: result(0) }, + { name: 'arrayPair', args: [[null, null]], expected: NaN }, + { name: 'objectEmpty', args: [{}], expected: NaN }, + { name: 'function', args: [functionValue], expected: NaN }, + ] +} + +/** + * `*` between a number and a bigint throws, so the pairs below never mix the + * two except in the case that proves it. Every pair is checked in both orders + * — see `commutative`. + * + * @type {readonly Case[]} + */ +const mulCases = [ + { name: 'nullByNull', args: [null, null], expected: 0 }, + { name: 'nullByZero', args: [null, 0], expected: 0 }, + { name: 'undefinedByZero', args: [undefined, 0], expected: NaN }, + { name: 'trueByZero', args: [true, 0], expected: 0 }, + { name: 'trueByOne', args: [true, 1], expected: 1 }, + { name: 'trueByTen', args: [true, 10], expected: 10 }, + { name: 'falseByZero', args: [false, 0], expected: 0 }, + { name: 'falseByOne', args: [false, 1], expected: 0 }, + { name: 'falseByTen', args: [false, 10], expected: 0 }, + { name: 'zeroByZero', args: [0, 0], expected: 0 }, + { name: 'zeroByOne', args: [0, 1], expected: 0 }, + { name: 'oneByOne', args: [1, 1], expected: 1 }, + { name: 'oneByMinusOne', args: [1, -1], expected: -1 }, + { name: 'oneByTen', args: [1, 10], expected: 10 }, + { name: 'minusOneByTen', args: [-1, 10], expected: -10 }, + { name: 'tenByTen', args: [10, 10], expected: 100 }, + { name: 'minusTenByTen', args: [-10, 10], expected: -100 }, + { name: 'bigZeroByZero', args: [0n, 0n], expected: 0n }, + { name: 'bigZeroByOne', args: [0n, 1n], expected: 0n }, + { name: 'bigOneByOne', args: [1n, 1n], expected: 1n }, + { name: 'bigOneByMinusOne', args: [1n, -1n], expected: -1n }, + { name: 'bigOneByTen', args: [1n, 10n], expected: 10n }, + { name: 'bigMinusOneByTen', args: [-1n, 10n], expected: -10n }, + { name: 'bigTenByTen', args: [10n, 10n], expected: 100n }, + { name: 'bigMinusTenByTen', args: [-10n, 10n], expected: -100n }, + { name: 'emptyStringByOne', args: ['', 1], expected: 0 }, + { name: 'stringTenByOne', args: ['10', 1], expected: 10 }, + { name: 'stringLetterByOne', args: ['a', 1], expected: NaN }, + { name: 'stringBigintByOne', args: ['1n', 1], expected: NaN }, + { name: 'emptyArrayByOne', args: [[], 1], expected: 0 }, + { name: 'arrayTenByOne', args: [[10], 1], expected: 10 }, + { name: 'arrayStringTenByOne', args: [['10'], 1], expected: 10 }, + { name: 'arrayPairByOne', args: [[0, 0], 1], expected: NaN }, + { name: 'emptyObjectByOne', args: [{}, 1], expected: NaN }, + { name: 'numberByBigint', args: [1, 1n], expected: throws }, +] + +const hexadecimalBigint = + 'nanvm-lib prints bigints in hexadecimal; see nanvm-lib/todo/bigint-decimal-string-coercion.md' + +/** + * `String(x)`. + * + * A function's string form is its source text, which no two engines have to + * agree on, so it is not shared data — [`proof.f.mjs`](./proof.f.mjs) checks + * the JavaScript side separately. + * + * @type {readonly Case[]} + */ +const stringCoercionCases = [ + { name: 'number', args: [123], expected: '123' }, + { name: 'negativeNumber', args: [-456], expected: '-456' }, + { name: 'zero', args: [0], expected: '0' }, + { name: 'negativeZero', args: [-0], expected: '0' }, + { name: 'infinity', args: [Infinity], expected: 'Infinity' }, + { name: 'negativeInfinity', args: [-Infinity], expected: '-Infinity' }, + { name: 'nan', args: [NaN], expected: 'NaN' }, + { name: 'booleanTrue', args: [true], expected: 'true' }, + { name: 'booleanFalse', args: [false], expected: 'false' }, + { name: 'null', args: [null], expected: 'null' }, + { name: 'undefined', args: [undefined], expected: 'undefined' }, + { name: 'string', args: ['already'], expected: 'already' }, + { name: 'bigint', args: [123n], expected: '123', rust: hexadecimalBigint }, + { name: 'negativeBigint', args: [-456n], expected: '-456', rust: hexadecimalBigint }, + { name: 'emptyArray', args: [[]], expected: '' }, + { name: 'singletonArray', args: [[1]], expected: '1' }, + { name: 'array', args: [[1, 2, 3]], expected: '1,2,3' }, + { name: 'nestedArray', args: [[1, [2, 3], 4]], expected: '1,2,3,4' }, + { name: 'arrayWithNullish', args: [[null, undefined, 1]], expected: ',,1' }, + { name: 'emptyObject', args: [{}], expected: '[object Object]' }, + { name: 'object', args: [{ a: 1 }], expected: '[object Object]' }, +] + +/** @type {Data} */ +export const data = { + eq: { + shared: { + emptyArray: [], + stringArray: ['0'], + object: { '0': '0' }, + }, + cases: [ + { name: 'nullByNull', a: null, b: null, eq: true }, + { name: 'undefinedByUndefined', a: undefined, b: undefined, eq: true }, + { name: 'nullByUndefined', a: null, b: undefined, eq: false }, + { name: 'trueByTrue', a: true, b: true, eq: true }, + { name: 'falseByFalse', a: false, b: false, eq: true }, + { name: 'trueByFalse', a: true, b: false, eq: false }, + { name: 'falseByUndefined', a: false, b: undefined, eq: false }, + { name: 'falseByNull', a: false, b: null, eq: false }, + { name: 'numberBySameNumber', a: 2.3, b: 2.3, eq: true }, + { name: 'numberByOtherNumber', a: 2.3, b: -5.4, eq: false }, + { name: 'nanByNan', a: NaN, b: NaN, eq: false }, + { name: 'zeroByNegativeZero', a: 0, b: -0, eq: true }, + { name: 'infinityByInfinity', a: Infinity, b: Infinity, eq: true }, + { + name: 'negativeInfinityByNegativeInfinity', + a: -Infinity, + b: -Infinity, + eq: true, + }, + { name: 'infinityByNegativeInfinity', a: Infinity, b: -Infinity, eq: false }, + { name: 'undefinedByNan', a: undefined, b: NaN, eq: false }, + { name: 'undefinedByZero', a: undefined, b: 0, eq: false }, + { name: 'stringBySameString', a: 'hello', b: 'hello', eq: true }, + { name: 'stringByOtherString', a: 'hello', b: 'world', eq: false }, + { name: 'zeroByStringZero', a: 0, b: '0', eq: false }, + { name: 'bigintBySameBigint', a: 12n, b: 12n, eq: true }, + { name: 'bigintByNegatedBigint', a: 12n, b: -12n, eq: false }, + { name: 'bigintByOtherBigint', a: 12n, b: 13n, eq: false }, + { name: 'twelveByStringTwelve', a: 12n, b: '12', eq: false }, + { name: 'arrayByItself', a: ref('emptyArray'), b: ref('emptyArray'), eq: true }, + { name: 'arrayByEqualArray', a: [], b: [], eq: false }, + { name: 'stringArrayByItself', a: ref('stringArray'), b: ref('stringArray'), eq: true }, + { name: 'objectByItself', a: ref('object'), b: ref('object'), eq: true }, + { name: 'objectByEqualObject', a: ref('object'), b: { '0': '0' }, eq: false }, + ], + }, + groups: [ + { + op: 'unaryPlus', + cases: [ + ...numberCoercionCases(false), + { name: 'bigint', args: [0n], expected: throws }, + ], + }, + { + op: 'unaryMinus', + cases: [ + ...numberCoercionCases(true), + { name: 'bigintPositive', args: [1n], expected: -1n }, + { name: 'bigintNegative', args: [-1n], expected: 1n }, + ], + }, + { op: 'mul', commutative: true, cases: mulCases }, + { op: 'stringCoercion', cases: stringCoercionCases }, + ], +} diff --git a/fjs/nanvm/proof.f.mjs b/fjs/nanvm/proof.f.mjs new file mode 100644 index 0000000000..df50f3be07 --- /dev/null +++ b/fjs/nanvm/proof.f.mjs @@ -0,0 +1,173 @@ +/** + * The JavaScript reference for `nanvm-lib`'s operators. + * + * Every case in [`module.f.mjs`](./module.f.mjs) is run through the native + * JavaScript operators here, so the shared data is proven to describe + * JavaScript before `nanvm-lib/tests/test/generated.rs` holds `nanvm-lib` to + * it. This module contains no test cases of its own beyond the `jsOnly` + * section at the end — adding a case means editing the data. + * + * @module + * + * @import { Case, EqCase, Op, Struct, Value } from './types.ts' + */ + +import { assert, assertEq } from '../asserts/module.f.mjs' +import { data } from './module.f.mjs' + +const { entries, fromEntries, hasOwn, is } = Object + +/** + * Builds the JavaScript value a `Value` describes. + * + * `resolve` supplies the *already built* object a `ref` names — rebuilding it + * per reference would hand each side of a comparison its own object, which is + * exactly what a `ref` exists to avoid. + * + * @type {(resolve: (name: string) => unknown) => (v: Value) => unknown} + */ +const build = resolve => v => { + if (v === null) { return null } + if (typeof v === 'function') { + const info = v() + switch (info[0]) { + case 'function': { return () => 5 } + case 'ref': { return resolve(info[1]) } + case 'throw': { throw ['`throws` is not a value', info] } + } + } + if (Array.isArray(v)) { return v.map(build(resolve)) } + if (typeof v === 'object') { + return fromEntries(entries(v).map(([k, p]) => [k, build(resolve)(p)])) + } + return v +} + +/** Cases outside the `eq` group share nothing, so a `ref` is a mistake there. */ +const value = build(name => { throw ['unknown shared value', name] }) + +/** + * Applies an operator to already-built arguments. + * + * The `any` casts are the point of the exercise: these operators are being + * applied to operand types TypeScript rejects (`{} * 1`, `-[]`), which is + * exactly the coercion behaviour under test. + * + * @type {(op: Op) => (args: readonly unknown[]) => unknown} + */ +const apply = op => args => { + const [a, b] = /** @type {readonly any[]} */(args) + switch (op) { + case 'unaryPlus': { return +a } + case 'unaryMinus': { return -a } + case 'mul': { return a * b } + case 'stringCoercion': { return String(a) } + } +} + +/** `true` when a case's `expected` is `throws` rather than a value. */ +const isThrows = (/** @type {Value} */ expected) => + typeof expected === 'function' && expected()[0] === 'throw' + +/** + * Every argument order a case is checked in: one, or both for a commutative + * operator. The Rust printer applies the same rule. + * + * @type {(commutative: boolean) => (c: Case) => readonly (readonly[string, readonly Value[]])[]} + */ +const orders = commutative => c => commutative + ? [[c.name, c.args], [`${c.name}Swapped`, c.args.toReversed()]] + : [[c.name, c.args]] + +/** + * The leaf tests of one group, keyed by case name. + * + * Throwing cases go under a nested `throw` key — the framework's structural + * way of declaring that a test is expected to throw. A throwing leaf stops at + * its first exception, which is why each argument order is its own leaf. + * + * @type {(op: Op) => (commutative: boolean) => (cases: readonly Case[]) => object} + */ +const group = op => commutative => cases => { + /** @type {(c: Case) => readonly (readonly[string, () => void])[]} */ + const leaves = c => { + const { expected } = c + /** @type {(args: readonly Value[]) => () => void} */ + const fn = isThrows(expected) + ? args => () => { apply(op)(args.map(value)) } + : args => () => { + // `Object.is` rather than `===`, so `NaN` matches `NaN` and + // `0` does not match `-0`; the Rust side compares the same way. + const result = apply(op)(args.map(value)) + const e = value(expected) + assert(is(result, e), [result, 'is not', e]) + } + return orders(commutative)(c).map(([name, args]) => [name, fn(args)]) + } + const ok = cases.filter(c => !isThrows(c.expected)).flatMap(leaves) + const bad = cases.filter(c => isThrows(c.expected)).flatMap(leaves) + return bad.length === 0 + ? fromEntries(ok) + : { ...fromEntries(ok), throw: fromEntries(bad) } +} + +const eqProof = (() => { + const { shared, cases } = data.eq + // Built once, so two `ref`s to the same name really are the same object. + const built = fromEntries(entries(shared).map(([k, v]) => [k, value(v)])) + const operand = build(name => { + assert(hasOwn(built, name), ['unknown shared value', name]) + return built[name] + }) + /** @type {(c: EqCase) => readonly[string, () => void]} */ + const leaf = c => [c.name, () => { + const a = /** @type {any} */(operand(c.a)) + const b = /** @type {any} */(operand(c.b)) + assertEq(a === b, c.eq, [a, c.eq ? '===' : '!==', b]) + assertEq(b === a, c.eq) + }] + return fromEntries(cases.map(leaf)) +})() + +/** + * Behaviour that is real JavaScript but has no `nanvm-lib` counterpart to + * share data with, so it stays here instead of in `module.f.mjs`. + */ +const jsOnly = { + /** + * `Object.is` distinguishes `0` from `-0` where `===` does not; the whole + * corpus relies on that, so it is checked directly. + */ + negativeZero: () => { + assert(is(-0, -0)) + assert(!is(0, -0)) + assert(is(NaN, NaN)) + }, + /** A function's string form is engine-specific — only its type is fixed. */ + functionToString: () => { + assertEq(typeof String(() => 5), 'string') + }, + /** + * `ToPrimitive` consults a `toString` method. `nanvm-lib` has no object + * methods yet, so these cases cannot be shared; see + * `nanvm-lib/todo/mvp-roadmap.md`. + */ + toStringMethod: () => { + assertEq(String({ toString: () => 'custom string' }), 'custom string') + }, + throw: { + toStringThrows: () => String({ toString: () => { throw 'Custom error' } }), + toStringNotAFunction: () => String(/** @type {any} */({ toString: 'hello' })), + toStringNotPrimitive: () => String({ toString: () => [] }), + /** `throws` describes a case's outcome; it is not a value to build. */ + throwsIsNotAValue: () => value(() => ['throw']), + unknownRef: () => value(() => ['ref', 'nope']), + }, +} + +export const proof = { + eq: eqProof, + ...fromEntries(data.groups.map( + g => [g.op, group(g.op)(g.commutative === true)(g.cases)])), + jsOnly, +} diff --git a/fjs/nanvm/rust/module.f.mjs b/fjs/nanvm/rust/module.f.mjs new file mode 100644 index 0000000000..29a5f15568 --- /dev/null +++ b/fjs/nanvm/rust/module.f.mjs @@ -0,0 +1,166 @@ +/** + * Prints the shared operator test data as Rust. + * + * The output is `nanvm-lib/tests/test/generated.rs`: one statement per case, + * calling the hand-written helpers in `nanvm-lib/tests/test/harness.rs`. Only + * the helpers are written by hand — a new operator case is added to + * [`../module.f.mjs`](../module.f.mjs) and appears on both the JavaScript and + * the Rust side at once. + * + * Literal syntax comes from [`fjs/media/rust`](../../media/rust/module.f.mjs); + * what is specific to this module is the `nanvm-lib` API the statements target. + * + * Every emitted function carries `#[rustfmt::skip]`: the line layout here is + * one statement per case, and `cargo fmt -- --check` runs in CI, so the + * printer would otherwise have to reproduce rustfmt's wrapping exactly. + * + * @module + * + * @import { Case, Data, Eq, Group, Op, Value } from '../types.ts' + * + * @example + * + * ```js + * import { generate } from './module.f.mjs' + * import { data } from '../module.f.mjs' + * + * generate(data) // the contents of `nanvm-lib/tests/test/generated.rs` + * ``` + */ + +import { + f64Literal, + i64Literal, + snakeCase, + stringLiteral, +} from '../../media/rust/module.f.mjs' + +const { entries } = Object + +const indent = ' ' + +/** + * Where this printer's output goes, relative to the repository root. + * + * `tests/test/` rather than `tests/`: cargo turns every `tests/*.rs` into its + * own test target, so a generated file directly in `tests/` would be built as + * a second, harness-less crate. Inside `tests/test/` it is an ordinary + * submodule of the `tests/test/main.rs` target. + */ +export const directory = 'nanvm-lib/tests/test' + +/** @type {string} */ +export const path = `${directory}/generated.rs` + +/** + * A Rust expression of type `Any`. + * + * Every use site fixes `A`, so no expression needs a turbofish: the harness + * helpers take `Any` arguments and the shared `let` bindings are annotated. + * + * @type {(v: Value) => string} + */ +export const valueExpr = v => { + if (v === null) { return 'Nullish::Null.to_any()' } + if (typeof v === 'function') { + const info = v() + switch (info[0]) { + case 'function': { return 'function_any()' } + case 'ref': { return `${snakeCase(info[1])}.clone()` } + case 'throw': { throw ['`throws` is not a value', info] } + } + } + switch (typeof v) { + case 'undefined': { return 'Nullish::Undefined.to_any()' } + case 'boolean': { return `${v}.to_any()` } + case 'number': { return `(${f64Literal(v)}).to_any()` } + case 'string': { return `string_any(${stringLiteral(v)})` } + case 'bigint': { return `bigint_any(${i64Literal(v)})` } + } + if (Array.isArray(v)) { + const items = /** @type {readonly Value[]} */(v) + return items.length === 0 + ? 'Array::default().to_any()' + : `[${items.map(valueExpr).join(', ')}].to_array().to_any()` + } + const properties = entries(v) + return properties.length === 0 + ? 'Object::default().to_any()' + : `[${properties.map( + ([k, p]) => `(string_key(${stringLiteral(k)}), ${valueExpr(p)})`).join(', ')}].to_object().to_any()` +} + +/** @type {(op: Op) => (args: readonly string[]) => string} */ +export const call = op => args => { + switch (op) { + case 'unaryPlus': { return `Any::unary_plus(${args[0]})` } + case 'unaryMinus': { return `-(${args[0]})` } + case 'mul': { return `${args[0]} * ${args[1]}` } + case 'stringCoercion': { return `${args[0]}.to_string().map(|v| v.to_any())` } + } +} + +/** + * Comments out a statement `nanvm-lib` cannot pass yet, keeping the case + * visible in the generated file as the work still to do. + * + * @type {(reason: string|undefined) => (statement: string) => readonly string[]} + */ +const emit = reason => statement => reason === undefined + ? [`${indent}${statement}`] + : [`${indent}// TODO: ${reason}`, `${indent}// ${statement}`] + +/** + * Every argument order a case is checked in — both for a commutative + * operator, matching what the JavaScript proof does. + * + * @type {(commutative: boolean) => (c: Case) => readonly (readonly[string, readonly Value[]])[]} + */ +const orders = commutative => c => commutative + ? [[c.name, c.args], [`${c.name}Swapped`, c.args.toReversed()]] + : [[c.name, c.args]] + +/** @type {(expected: Value) => (name: string) => (result: string) => string} */ +const assertion = expected => name => result => + typeof expected === 'function' && expected()[0] === 'throw' + ? `check_throws::(${stringLiteral(name)}, ${result});` + : `check::(${stringLiteral(name)}, ${result}, ${valueExpr(expected)});` + +/** @type {(g: Group) => readonly string[]} */ +const groupFn = g => [ + '#[rustfmt::skip]', + `fn ${snakeCase(g.op)}() {`, + ...g.cases.flatMap(c => orders(g.commutative === true)(c).flatMap( + ([name, args]) => emit(c.rust)( + assertion(c.expected)(name)(call(g.op)(args.map(valueExpr)))))), + '}', + '', +] + +/** @type {(eq: Eq) => readonly string[]} */ +const eqFn = eq => [ + '#[rustfmt::skip]', + 'fn eq() {', + ...entries(eq.shared).map( + ([k, v]) => `${indent}let ${snakeCase(k)}: Any = ${valueExpr(v)};`), + ...eq.cases.flatMap(c => emit(c.rust)( + `check_eq::(${stringLiteral(c.name)}, ${valueExpr(c.a)}, ${valueExpr(c.b)}, ${c.eq});`)), + '}', + '', +] + +/** @type {(data: Data) => string} */ +export const generate = data => [ + '// @generated by `npm run ci-update` from `fjs/nanvm/module.f.mjs`.', + '// Do not edit: change the shared operator test data and regenerate.', + '', + 'use super::harness::*;', + '', + ...eqFn(data.eq), + ...data.groups.flatMap(groupFn), + 'pub fn all() {', + `${indent}eq::();`, + ...data.groups.map(g => `${indent}${snakeCase(g.op)}::();`), + '}', + '', +].join('\n') diff --git a/fjs/nanvm/rust/proof.f.mjs b/fjs/nanvm/rust/proof.f.mjs new file mode 100644 index 0000000000..a56d9588d2 --- /dev/null +++ b/fjs/nanvm/rust/proof.f.mjs @@ -0,0 +1,119 @@ +/** + * Proofs for the Rust printer. + * + * The interesting assertion is `generate`'s on a tiny synthetic corpus: it + * pins the exact text of every construct the printer can emit, so a change in + * layout is a visible diff here and not only in the generated file. + * + * @module + * + * @import { Data } from '../types.ts' + */ + +import { assert, assertEq } from '../../asserts/module.f.mjs' +import { data, functionValue, ref, throws } from '../module.f.mjs' +import { call, directory, generate, path, valueExpr } from './module.f.mjs' +import { snakeCase } from '../../media/rust/module.f.mjs' + +/** + * One case of every shape the printer can emit: a shared value and a + * reference to it, a skipped case, a throwing case, and a commutative binary + * operator. + * + * @type {Data} + */ +const sample = { + eq: { + shared: { emptyArray: [] }, + cases: [ + { name: 'itself', a: ref('emptyArray'), b: ref('emptyArray'), eq: true }, + { name: 'skipped', a: null, b: null, eq: true, rust: 'not yet' }, + ], + }, + groups: [ + { op: 'unaryPlus', cases: [{ name: 'bigint', args: [0n], expected: throws }] }, + { + op: 'mul', + commutative: true, + cases: [{ name: 'oneByTwo', args: [1, 2], expected: 2 }], + }, + ], +} + +const expected = `// @generated by \`npm run ci-update\` from \`fjs/nanvm/module.f.mjs\`. +// Do not edit: change the shared operator test data and regenerate. + +use super::harness::*; + +#[rustfmt::skip] +fn eq() { + let empty_array: Any = Array::default().to_any(); + check_eq::("itself", empty_array.clone(), empty_array.clone(), true); + // TODO: not yet + // check_eq::("skipped", Nullish::Null.to_any(), Nullish::Null.to_any(), true); +} + +#[rustfmt::skip] +fn unary_plus() { + check_throws::("bigint", Any::unary_plus(bigint_any(0))); +} + +#[rustfmt::skip] +fn mul() { + check::("oneByTwo", (1f64).to_any() * (2f64).to_any(), (2f64).to_any()); + check::("oneByTwoSwapped", (2f64).to_any() * (1f64).to_any(), (2f64).to_any()); +} + +pub fn all() { + eq::(); + unary_plus::(); + mul::(); +} +` + +export const proof = { + path: () => { + assertEq(directory, 'nanvm-lib/tests/test') + assertEq(path, 'nanvm-lib/tests/test/generated.rs') + }, + valueExpr: () => { + assertEq(valueExpr(null), 'Nullish::Null.to_any()') + assertEq(valueExpr(undefined), 'Nullish::Undefined.to_any()') + assertEq(valueExpr(true), 'true.to_any()') + assertEq(valueExpr(false), 'false.to_any()') + assertEq(valueExpr(-0.3), '(-0.3f64).to_any()') + assertEq(valueExpr('a'), 'string_any("a")') + assertEq(valueExpr(-1n), 'bigint_any(-1)') + assertEq(valueExpr([]), 'Array::default().to_any()') + assertEq(valueExpr([null]), '[Nullish::Null.to_any()].to_array().to_any()') + assertEq(valueExpr({}), 'Object::default().to_any()') + assertEq( + valueExpr({ k: null }), + '[(string_key("k"), Nullish::Null.to_any())].to_object().to_any()') + assertEq(valueExpr(functionValue), 'function_any()') + assertEq(valueExpr(ref('stringArray')), 'string_array.clone()') + }, + call: () => { + assertEq(call('unaryPlus')(['x']), 'Any::unary_plus(x)') + assertEq(call('unaryMinus')(['x']), '-(x)') + assertEq(call('mul')(['x', 'y']), 'x * y') + assertEq(call('stringCoercion')(['x']), 'x.to_string().map(|v| v.to_any())') + }, + generate: () => { + assertEq(generate(sample), expected) + }, + generateData: () => { + // The real corpus, which is what `ci-update` writes. Only its shape is + // asserted here: its contents are checked by `cargo test`. + const result = generate(data) + assert(result.endsWith('}\n'), result) + assert(result.includes('pub fn all() {'), result) + for (const g of data.groups) { + assert(result.includes(`fn ${snakeCase(g.op)}() {`), g.op) + assert(result.includes(`${snakeCase(g.op)}::();`), g.op) + } + }, + throw: { + throwsIsNotAValue: () => valueExpr(throws), + }, +} diff --git a/fjs/nanvm/types.ts b/fjs/nanvm/types.ts new file mode 100644 index 0000000000..28fb98d1b3 --- /dev/null +++ b/fjs/nanvm/types.ts @@ -0,0 +1,125 @@ +/** + * Type-level API for the shared operator test data. + * + * The data described here is the single source of truth for operator + * behaviour: [`proof.f.mjs`](./proof.f.mjs) runs it against a standard + * JavaScript engine, and [`rust/module.f.mjs`](./rust/module.f.mjs) prints it + * as the Rust tests in [`test/generated.rs`](./test/generated.rs). + * + * @module + */ + +/** + * A value under test, written as itself. + * + * The shape follows `fjs/types/rtti`, where a constant is its own schema and a + * thunk describes anything that needs a tag: `2.3`, `'a'`, `12n`, `[1, 2]`, + * and `{ a: 1 }` mean exactly what they look like, and the only tagged forms + * are the ones a literal cannot express — see {@link Special}. + * + * Writing operands as plain JavaScript is what makes the corpus readable + * (`args: [[2.3]], expected: 2.3` rather than a tree of constructor calls) and + * costs nothing: both consumers already have to walk the value, and `typeof` + * plus `Array.isArray` recovers everything a tag would have carried. + */ +export type Value = Const | Special + +/** A value that is its own description. */ +export type Const = + | null + | undefined + | boolean + | number + | string + | bigint + | readonly Value[] + | Struct + +/** An object value. Property order is the order the Rust printer emits. */ +export type Struct = { readonly [k in string]?: Value } + +/** + * Something no literal can express, described by a thunk. + * + * A function in the data is therefore always a *description*, never a value + * that happens to be a function — `functionValue` is how the data says "a + * function". + */ +export type Special = () => Info + +/** + * What a {@link Special} describes. + * + * - `function` — a function value. Every operator here coerces one through + * `ToPrimitive`, which never inspects it, so there is nothing to carry. + * - `ref` — one of the {@link Eq} `shared` values, so the *same* object + * reaches both sides of a comparison. + * - `throw` — not a value at all: the case must throw. Valid only as a + * {@link Case}'s `expected`. + */ +export type Info = + | readonly ['function'] + | readonly ['ref', string] + | readonly ['throw'] + +/** The operators covered by the shared data. */ +export type Op = 'unaryPlus' | 'unaryMinus' | 'mul' | 'stringCoercion' + +/** + * One operator test case. + * + * `expected` is compared with `Object.is`, so `NaN` matches `NaN` and `0` does + * not match `-0`; `throws` there means the operation must throw, and the + * exception value — being engine-specific — is not part of the data. + * + * `rust` marks a case `nanvm-lib` does not implement yet: the value is the + * reason, the generated Rust keeps the case as a commented-out `TODO`, and + * the JavaScript proof still runs it. Removing the property is what turns the + * case on for Rust — the gap list is data, not prose in a README. + */ +export type Case = { + readonly name: string + readonly args: readonly Value[] + readonly expected: Value + readonly rust?: string +} + +/** + * The cases of one operator. + * + * `commutative` additionally checks every case with its arguments swapped, + * which is what the hand-written tests did for `*`. + */ +export type Group = { + readonly op: Op + readonly commutative?: boolean + readonly cases: readonly Case[] +} + +/** One strict-equality (`===`) case; `eq` is the expected result. */ +export type EqCase = { + readonly name: string + readonly name2?: string + readonly a: Value + readonly b: Value + readonly eq: boolean + readonly rust?: string +} + +/** + * Strict-equality cases plus the values they share. + * + * Equality of arrays and objects is reference equality in both JavaScript and + * `nanvm-lib`, so a case can only express "the same object" by naming a value + * in `shared` and reaching it with a `ref`. + */ +export type Eq = { + readonly shared: Struct + readonly cases: readonly EqCase[] +} + +/** The whole shared test corpus. */ +export type Data = { + readonly eq: Eq + readonly groups: readonly Group[] +} diff --git a/fjs/nanvm/update/module.f.mjs b/fjs/nanvm/update/module.f.mjs new file mode 100644 index 0000000000..291cd308e1 --- /dev/null +++ b/fjs/nanvm/update/module.f.mjs @@ -0,0 +1,33 @@ +/** + * Writes the generated Rust operator tests. + * + * The printer in [`../rust/module.f.mjs`](../rust/module.f.mjs) is pure; this + * module is the thin effectful shell around it, invoked from `ci-update` so + * the CI drift check regenerates the file on every pull request and fails + * when the committed copy is stale. + * + * @module + * + * @import { Effect } from '../../effects/types.ts' + * @import { Mkdir, NodeProgram, WriteFile } from '../../effects/node/types.ts' + */ + +import { mapStep, step } from '../../effects/module.f.mjs' +import { mkdir, writeUtf8File } from '../../effects/node/module.f.mjs' +import { unwrap } from '../../types/result/module.f.mjs' +import { data } from '../module.f.mjs' +import { directory, generate, path } from '../rust/module.f.mjs' + +/** + * Regenerates `nanvm-lib/tests/test/generated.rs` from the shared test data. + * + * @type {() => Effect} + */ +export const generateRustTests = () => { + const directoryReady = mapStep(mkdir(directory, { recursive: true }), unwrap) + const written = step(directoryReady, () => writeUtf8File(path, generate(data))) + return mapStep(written, unwrap) +} + +/** @type {NodeProgram} */ +export const main = () => mapStep(generateRustTests(), () => 0) diff --git a/fjs/nanvm/update/proof.f.mjs b/fjs/nanvm/update/proof.f.mjs new file mode 100644 index 0000000000..f5e0236efb --- /dev/null +++ b/fjs/nanvm/update/proof.f.mjs @@ -0,0 +1,32 @@ +/** + * Proofs for the generated-Rust writer. + * + * @module + */ + +import { assert, assertEq } from '../../asserts/module.f.mjs' +import { step } from '../../effects/module.f.mjs' +import { readUtf8File } from '../../effects/node/module.f.mjs' +import { + defaultNodeProgramOptions, + emptyState, + virtual, +} from '../../effects/node/virtual/module.f.mjs' +import { data } from '../module.f.mjs' +import { generate, path } from '../rust/module.f.mjs' +import { generateRustTests, main } from './module.f.mjs' + +export const proof = { + generateRustTests: () => { + // The target directory does not exist in `emptyState`, so this also + // covers the `mkdir` the writer does before the file write. + const written = step(generateRustTests(), () => readUtf8File(path)) + const [, [tag, result]] = virtual(emptyState)(written) + assert(tag === 'ok', result) + assertEq(result, generate(data)) + }, + main: () => { + const [, result] = virtual(emptyState)(main(defaultNodeProgramOptions)) + assertEq(result, 0) + }, +} diff --git a/nanvm-lib/tests/README.md b/nanvm-lib/tests/README.md index 5afbd2669c..a9e9b13d0c 100644 --- a/nanvm-lib/tests/README.md +++ b/nanvm-lib/tests/README.md @@ -1,121 +1,28 @@ # Tests -[`proof.f.ts`](proof.f.ts) is the JavaScript reference — it runs the same operations against a standard JS engine. -[`test.rs`](test.rs) is the Rust counterpart — it runs the same operations against `nanvm-lib`. - -The goal is a near 1-to-1 match so that any divergence signals either a missing Rust test or an unimplemented feature. - -## Coverage map - -`[x]` = present, `[ ]` = missing. - -### `eq` / strict equality (`===` / `!==`) - -| Test case | `proof.f.ts` | `test.rs` | Notes | -|------------------------|:---:|:---:|-------| -| `nullish` | [x] | [x] | `nullish_eq` | -| `boolean.boolean` | [x] | [x] | `bool_eq` | -| `boolean.nullish` | [x] | [x] | `bool_eq` | -| `number.number` | [x] | [x] | `number_eq` — includes NaN, ±0, ±Inf | -| `number.nullish` | [x] | [x] | `number_eq` | -| `string.string` | [x] | [x] | `string_eq` | -| `string.number` | [x] | [x] | `old_eq` | -| `bigint.bigint` | [x] | [x] | `bigint_eq` | -| `array.array` | [x] | [x] | `array_eq` | -| `object.object` | [x] | [x] | `object_eq` | - -### `unary_plus` (`+n`) - -| Test case | `proof.f.ts` | `test.rs` | Notes | -|------------------------|:---:|:---:|-------| -| `null` | [x] | [x] | | -| `undefined` | [x] | [x] | | -| `boolean.false` | [x] | [x] | | -| `boolean.true` | [x] | [x] | | -| `number.zero` | [x] | [x] | | -| `number.positive` | [x] | [x] | | -| `number.negative` | [x] | [x] | | -| `string.empty` | [x] | [x] | | -| `string.zero` | [x] | [x] | | -| `string.positive` | [x] | [x] | TS tests `"2.3"`, Rust tests `"2.3e2"` — same concept | -| `string.nan` | [x] | [x] | | -| `bigint.throw` | [x] | [x] | | -| `array.empty` | [x] | [x] | | -| `array.single_number` | [x] | [x] | | -| `array.single_string` | [x] | [x] | TS tests `["-2.3"]→-2.3`, Rust tests `["0.3"]→0.3` — Rust missing negative case | -| `array.multiple` | [x] | [x] | | -| `object.empty` | [x] | [x] | | -| `function` | [x] | [x] | | - -### `unary_minus` (`-n`) - -| Test case | `proof.f.ts` | `test.rs` | Notes | -|------------------------|:---:|:---:|-------| -| `null` | [x] | [x] | | -| `undefined` | [x] | [x] | | -| `boolean.false` | [x] | [x] | | -| `boolean.true` | [x] | [x] | | -| `number.zero` | [x] | [x] | | -| `number.positive` | [x] | [x] | | -| `number.negative` | [x] | [x] | | -| `string.empty` | [x] | [x] | | -| `string.zero` | [x] | [x] | | -| `string.positive` | [x] | [x] | | -| `string.nan` | [x] | [x] | | -| `bigint.positive` | [x] | [x] | | -| `bigint.negative` | [x] | [x] | | -| `array.empty` | [x] | [x] | | -| `array.single_number` | [x] | [x] | | -| `array.single_string` | [x] | [x] | | -| `array.multiple` | [x] | [x] | | -| `object.empty` | [x] | [ ] | `{}` → NaN missing in Rust | -| `function` | [x] | [x] | | - -### `stringCoercion` (`String(x)`) - -| Test case | `proof.f.ts` | `test.rs` | Notes | -|--------------------------------|:---:|:---:|-------| -| `number` | [x] | [x] | `number_coerce_to_string` | -| `bool` | [x] | [ ] | `true`/`false` → `"true"`/`"false"` missing in Rust | -| `null` | [x] | [ ] | `null` → `"null"` missing in Rust | -| `undefined` | [x] | [ ] | `undefined` → `"undefined"` missing in Rust | -| `bigint` | [x] | [ ] | `123n` → `"123"` missing in Rust | -| `array` | [x] | [x] | `array_coerce_to_string` — Rust additionally tests nested arrays | -| `func` | [x] | [ ] | `typeof String(fn) === "string"` missing in Rust | -| `object.norm` | [x] | [ ] | `{}` → `"[object Object]"` missing in Rust | -| `object.toString` | [x] | [ ] | custom `toString()` method missing in Rust | -| `object.toStringThrow` | [x] | [ ] | throwing `toString()` missing in Rust | -| `object.toStringNotFunc` | [x] | [ ] | non-function `toString` missing in Rust | -| `object.toStringNonPrimitive` | [x] | [ ] | `toString()` returning non-primitive missing in Rust | - -### `mul` (`*`) - -| Test case | `proof.f.ts` | `test.rs` | Notes | -|------------------------|:---:|:---:|-------| -| `mul` (all cases) | [x] | [x] | Full table covering null, bool, number, bigint, string, array, object | - -### BigInt-specific — only in `test.rs` - -| Test case | `proof.f.ts` | `test.rs` | Notes | -|------------------------|:---:|:---:|-------| -| `bigint_add` | [ ] | [x] | Addition at `Any` level | -| `bigint_mul` | [ ] | [x] | Multiplication with large multi-limb values | -| `bigint_negative_zero` | [ ] | [x] | Rust normalization: `-0n === 0n` | - -### Infrastructure — only in `test.rs` - -| Test case | `proof.f.ts` | `test.rs` | Notes | -|------------------------|:---:|:---:|-------| -| `serialization` | [ ] | [x] | Round-trip serialize/deserialize for all types | -| `format_fn` | [ ] | [x] | Rust `Debug` formatting of `Function` | - -## Summary - -| Gap | Action needed | -|-----|---------------| -| `stringCoercion` for bool/null/undefined/bigint/func/object | Add Rust tests in `test.rs` | -| `unary_minus.object.empty` | Add Rust test case | -| `bigint_add` / `bigint_mul` | Add TS tests in `proof.f.ts` | -| `serialization` | JS serialization is out of scope (VM-internal), keep Rust-only | -| `format_fn` / `bigint_negative_zero` | Rust-specific, keep Rust-only | -| `old_eq` in `test.rs` | Redundant — overlaps with `nullish_eq`/`bool_eq`/`number_eq`/etc.; consider removing | +Operator behaviour is **not** written here. It is described once, as data, in +[`fjs/nanvm`](../../fjs/nanvm/README.md), and arrives in this crate as +[`test/generated.rs`](test/generated.rs) — so a case is written once and checked +twice, against a JavaScript engine and against `nanvm-lib`. + +| File | Role | +|---|---| +| [`test/main.rs`](test/main.rs) | Hand-written tests with no JavaScript counterpart. | +| [`test/harness.rs`](test/harness.rs) | Value constructors and assertions the generated file calls. | +| [`test/generated.rs`](test/generated.rs) | **Generated. Do not edit.** One statement per case. | + +`test/main.rs` rather than `test.rs`: cargo makes every `tests/*.rs` its own +test target, so the generated file and the harness have to live in a +subdirectory to stay ordinary submodules, and a subdirectory's entry point is +`main.rs`. + +## Changing what is tested + +An operator case belongs in [`fjs/nanvm/module.f.mjs`](../../fjs/nanvm/module.f.mjs); +`npm run ci-update` regenerates `test/generated.rs` from it, and CI fails if the +committed copy is stale. + +What stays here is everything with no JavaScript counterpart: `try_into` out of +`Any`, `Debug` formatting, multi-limb bigint arithmetic, serialization +round-trips, and the exact text of `nanvm-lib`'s own error messages. These are +properties of the VM, not of JavaScript, so there is nothing to share. diff --git a/nanvm-lib/tests/proof.f.ts b/nanvm-lib/tests/proof.f.ts deleted file mode 100644 index 5ae7fc9a58..0000000000 --- a/nanvm-lib/tests/proof.f.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { assert } from '../../fjs/asserts/module.f.mjs' - -const { is } = Object; - -const ois = (a: unknown) => (b: unknown): void => { - assert(is(a, b), [a, 'is', b]) -} - -const { isNaN } = Number; - -const nanRes = (op: (n: unknown) => unknown) => (n: unknown): void => { - const result = op(n); - assert(isNaN(result), result) -} - -const stringCoercion = String -const multiply = (a: any) => (b: any): unknown => a * b -const multiplyEq = (a: any) => (b: any) => (expected: unknown): void => { - ois(multiply(a)(b))(expected) - ois(multiply(b)(a))(expected) -} - -export const proof = { - eq: () => { - const e = (a: unknown) => (b: unknown): void => { - if (a === b) { } else { throw [a, '===', b] } - } - - const n = (a: unknown) => (b: unknown): void => { - if (a !== b) { } else { throw [a, '!==', b] } - } - return { - nullish: () => { - e(null)(null) - e(undefined)(undefined) - n(null)(undefined) - }, - boolean: { - boolean: () => { - e(true)(true) - e(false)(false) - n(true)(false) - }, - nullish: () => { - n(false)(undefined) - n(false)(null) - } - }, - number: { - number: () => { - e(2.3)(2.3) - n(2.3)(-5.4) - n(NaN)(NaN) - e(0)(-0) - if (!is(-0, -0)) { throw -0 } - if (is(0, -0)) { throw -0 } - e(Infinity)(Infinity) - e(-Infinity)(-Infinity) - n(Infinity)(-Infinity) - }, - nullish: () => { - n(undefined)(NaN) - n(undefined)(0) - } - }, - string: { - string: () => { - e("hello")("hello") - n("hello")("world") - }, - number: () => { - n(0)("0") - } - }, - bigint: { - bigint: () => { - e(12n)(12n) - n(12n)(-12n) - n(12n)(13n) - } - }, - array: { - array: () => { - const a: any = [] - e(a)(a) - n([])([]) - const a0 = ['0'] - e(a0)(a0) - } - }, - object: { - object: () => { - const o = { '0': '0' } - e(o)(o) - n(o)({ '0': '0' }) - } - } - } - }, - unary_plus: () => { - const op = (n: any) => +n - const nan = nanRes(op) - return { - null: () => ois(op(null))(0), - undefined: () => nan(undefined), - boolean: { - false: () => ois(op(false))(0), - true: () => ois(op(true))(1) - }, - number: { - zero: () => ois(op(0))(0), - positive: () => ois(op(2.3))(2.3), - negative: () => ois(op(-2.3))(-2.3) - }, - string: { - empty: () => ois(op(""))(0), - zero: () => ois(op("0"))(0), - positive: () => ois(op("2.3"))(2.3), - nan: () => nan("a") - }, - bigint: { - throw: () => op(0n), - }, - array: { - empty: () => ois(op([]))(0), - single_number: () => ois(op([2.3]))(2.3), - single_string: () => ois(op(["-2.3"]))(-2.3), - multiple: () => nan([null, null]) - }, - object: { - empty: () => nan({}) - // TODO: test objects with valueOf, toString functions - when Rust logic is implemented - }, - function: () => nan(op(() => {})) - } - }, - unary_minus: () => { - const op = (n: any) => -n - const nan = nanRes(op) - return { - null: () => ois(op(null))(-0), - undefined: () => nan(undefined), - boolean: { - false: () => ois(op(false))(-0), - true: () => ois(op(true))(-1) - }, - number: { - zero: () => ois(op(0))(-0), - positive: () => ois(op(2.3))(-2.3), - negative: () => ois(op(-2.3))(2.3) - }, - string: { - empty: () => ois(op(""))(-0), - zero: () => ois(op("0"))(-0), - positive: () => ois(op("2.3"))(-2.3), - nan: () => nan("a") - }, - bigint: { - positive: () => ois(op(1n))(-1n), - negative: () => ois(op(-1n))(1n), - }, - array: { - empty: () => ois(op([]))(-0), - single_number: () => ois(op([2.3]))(-2.3), - single_string: () => ois(op(["-2.3"]))(2.3), - multiple: () => nan([null, null]) - }, - object: { - empty: () => nan({}) - // TODO: test objects with valueOf, toString functions - when Rust logic is implemented - }, - function: () => nan(op(() => {})) - } - }, - mul: () => { - return { - nullish: () => { - multiplyEq(null)(null)(0) - multiplyEq(null)(0)(0) - multiplyEq(undefined)(0)(NaN) - }, - boolean: () => { - multiplyEq(true)(0)(0) - multiplyEq(true)(1)(1) - multiplyEq(true)(10)(10) - multiplyEq(false)(0)(0) - multiplyEq(false)(1)(0) - multiplyEq(false)(10)(0) - }, - number: () => { - multiplyEq(0)(0)(0) - multiplyEq(0)(1)(0) - multiplyEq(1)(1)(1) - multiplyEq(1)(-1)(-1) - multiplyEq(1)(10)(10) - multiplyEq(-1)(10)(-10) - multiplyEq(10)(10)(100) - multiplyEq(-10)(10)(-100) - }, - bigint: () => { - multiplyEq(0n)(0n)(0n) - multiplyEq(0n)(1n)(0n) - multiplyEq(1n)(1n)(1n) - multiplyEq(1n)(-1n)(-1n) - multiplyEq(1n)(10n)(10n) - multiplyEq(-1n)(10n)(-10n) - multiplyEq(10n)(10n)(100n) - multiplyEq(-10n)(10n)(-100n) - }, - string: () => { - multiplyEq('')(1)(0) - multiplyEq('10')(1)(10) - multiplyEq('a')(1)(NaN) - multiplyEq('1n')(1)(NaN) - }, - array: () => { - multiplyEq([])(1)(0) - multiplyEq([10])(1)(10) - multiplyEq(['10'])(1)(10) - multiplyEq([0, 0])(1)(NaN) - }, - object: () => multiplyEq({})(1)(NaN) - } - }, - stringCoercion: { - number: () => { - if (stringCoercion(123) !== '123') { throw [123, 'toString', '123'] } - if (stringCoercion(-456) !== '-456') { throw [-456, 'toString', '-456'] } - if (stringCoercion(0) !== '0') { throw [0, 'toString', '0'] } - if (stringCoercion(-0) !== '0') { throw [0, 'toString', '0'] } - if (stringCoercion(1/(-0)) !== '-Infinity') { throw [0, 'toString', '-Infinity'] } - if (stringCoercion(Infinity) !== 'Infinity') { throw [Infinity, 'toString', 'Infinity'] } - if (stringCoercion(-Infinity) !== '-Infinity') { throw [-Infinity, 'toString', '-Infinity'] } - if (stringCoercion(1/-Infinity) !== '0') { throw [-Infinity, 'toString', '0'] } - if (stringCoercion(NaN) !== 'NaN') { throw [NaN, 'toString', 'NaN'] } - }, - bool: () => { - if (stringCoercion(true) !== 'true') { throw [true, 'toString', 'true'] } - if (stringCoercion(false) !== 'false') { throw [false, 'toString', 'false'] } - }, - null: () => { - if (stringCoercion(null) !== 'null') { throw [null, 'toString', 'null'] } - }, - undefined: () => { - if (stringCoercion(undefined) !== 'undefined') { throw [undefined, 'toString', 'undefined'] } - }, - bigint: () => { - if (stringCoercion(123n) !== '123') { throw [123n, 'toString', '123'] } - if (stringCoercion(-456n) !== '-456') { throw [-456n, 'toString', '-456'] } - }, - array: () => { - const arr = [1, 2, 3] - if (stringCoercion(arr) !== '1,2,3') { throw [arr, 'toString', '1,2,3'] } - }, - func: () => { - const func = () => 5 - if (typeof stringCoercion(func) !== 'string') { throw [func, 'toString'] } - // if (stringCoercion(func) !== '() => 5') { throw [func, 'toString', 'function result'] } - }, - object: { - norm: () => { - const obj = { a: 1, b: 2 } - if (stringCoercion(obj) !== '[object Object]') { throw [obj, 'toString', '[object Object]'] } - }, - toString: () => { - const x = { toString: () => 'custom string' } - if (stringCoercion(x) !== 'custom string') { throw [x, 'toString', 'custom string'] } - }, - toStringThrow: { - throw: () => { - const x = { toString: () => { throw new Error('Custom error') } } - stringCoercion(x) - } - }, - toStringNotFunc: { - throw: () => { - const x = { toString: 'hello' } - stringCoercion(x) - } - }, - toStringNonPrimitive: { - throw: () => { - const x = { toString: () => [] } - stringCoercion(x) - } - } - } - } -} diff --git a/nanvm-lib/tests/test.rs b/nanvm-lib/tests/test.rs deleted file mode 100644 index bf999d0b45..0000000000 --- a/nanvm-lib/tests/test.rs +++ /dev/null @@ -1,769 +0,0 @@ -use nanvm_lib::{ - common::{default::default, iter::Iter, serializable::Serializable}, - naive, - sign::Sign, - vm::{ - Any, Array, BigInt, Function, IContainer, IVm, Nullish, Object, Property, String, ToAny, - ToArray, ToObject, Unpacked, - }, -}; - -fn nullish_eq() { - let n0: Any = Nullish::Null.to_any(); - let n1 = Nullish::Null.to_any(); - assert_eq!(n0, n1); - let u0 = Nullish::Undefined.to_any(); - let u1 = Nullish::Undefined.to_any(); - assert_eq!(u0, u1); - assert_ne!(n0, u0); - - let x: Nullish = n0.try_into().unwrap(); - assert_eq!(x, Nullish::Null); -} - -fn bool_eq() { - let t0: Any = true.to_any(); - let t1 = true.to_any(); - assert_eq!(t0, t1); - let f0 = false.to_any(); - let f1 = false.to_any(); - assert_eq!(f0, f1); - assert_ne!(t0, f0); - - let x: bool = t0.try_into().unwrap(); - assert!(x); -} - -fn number_eq() { - // 0.5 - let a0: Any = 0.5.to_any(); - let a1 = 0.5.to_any(); - assert_eq!(a0, a1); - - // 3.0 - let b0 = 3.0.to_any(); - let b1 = 3.0.to_any(); - assert_eq!(b0, b1); - assert_ne!(a0, b0); - - // 0.0 and -0.0 - let pz = 0.0.to_any(); - let nz = (-0.0).to_any(); - assert_eq!(pz, nz); - assert_ne!(a0, pz); - - let nzf: f64 = nz.try_into().unwrap(); - let nzs = format!("{nzf}"); - assert_eq!(nzs, "-0"); - let x = 1.0 / nzf; - assert_ne!(x, f64::INFINITY); - assert_eq!(x, -f64::INFINITY); - - // Infinity - let i0 = f64::INFINITY.to_any(); - let i1 = f64::INFINITY.to_any(); - assert_eq!(i0, i1); - assert_ne!(a0, i0); - - // -Infinity - let ni0 = f64::NEG_INFINITY.to_any(); - let ni1 = f64::NEG_INFINITY.to_any(); - assert_eq!(ni0, ni1); - assert_ne!(i0, ni0); - let ni = x.to_any(); - assert_eq!(ni, ni0); - - // NaN - let nan0 = f64::NAN.to_any(); - let nan1 = f64::NAN.to_any(); - assert_ne!(nan0, nan1); - assert_ne!(i0, nan0); - let nan0n: f64 = nan0.try_into().unwrap(); - assert!(nan0n.is_nan()); - let nan1n: f64 = nan1.try_into().unwrap(); - assert!(nan1n.is_nan()); -} - -fn string_eq() { - let s0: Any = "Hello".into(); - let s1 = "Hello".into(); - assert_eq!(s0, s1); - let s2 = "World".into(); - assert_ne!(s0, s2); - - let s: String = s0.try_into().unwrap(); - assert_eq!(s, String::from("Hello")); - - let x = format!("{s:?}"); - assert_eq!(x, "\"Hello\""); -} - -fn object_eq() { - let e0: Any = Object::default().to_any(); - let e1 = Object::default().to_any(); - assert_eq!(e0, e0); - assert_ne!(e0, e1); - - let o0: Object = e0.try_into().unwrap(); - let o1: Object = e1.try_into().unwrap(); - assert_eq!(o0, o0); - assert_ne!(o0, o1); - - let x = format!("{o0:?}"); - assert_eq!(x, "{}"); -} - -fn array_eq() { - let e0: Any = Array::default().to_any(); - let e1 = Array::default().to_any(); - assert_eq!(e0, e0); - assert_ne!(e0, e1); - - let a0: Array = e0.try_into().unwrap(); - let a1: Array = e1.try_into().unwrap(); - assert_eq!(a0, a0); - assert_ne!(a0, a1); - - let x = format!("{a0:?}"); - assert_eq!(x, "[]"); -} - -fn bigint_eq() { - let b0: Any = BigInt::default().to_any(); - let b1 = BigInt::default().to_any(); - assert_eq!(b0, b1); - let z: BigInt<_> = b0.try_into().unwrap(); - assert_eq!(z, default()); - let x = format!("{z:?}"); - assert_eq!(x, "0n"); - - { - let bm: BigInt = i64::MIN.into(); - let x = format!("{bm:?}"); - // 0123456789ABCDEF - assert_eq!(x, "-0x8000000000000000n"); - let i: i64 = i64::MIN; - let m = i.overflowing_neg().0 as u64; - assert_eq!(m, 0x8000000000000000); - } - - { - let bm: BigInt = (i64::MIN + 1).into(); - let x = format!("{bm:?}"); - // 0123456789ABCDEF - assert_eq!(x, "-0x7FFFFFFFFFFFFFFFn"); - let i: i64 = i64::MIN + 1; - let m = i.overflowing_neg().0 as u64; - assert_eq!(m, 0x7FFFFFFFFFFFFFFF); - } - - { - let bm: BigInt = i64::MAX.into(); - let x = format!("{bm:?}"); - // 0123456789ABCDEF - assert_eq!(x, "0x7FFFFFFFFFFFFFFFn"); - } - - { - let bm: BigInt = u64::MAX.into(); - let x = format!("{bm:?}"); - // 0123456789ABCDEF - assert_eq!(x, "0xFFFFFFFFFFFFFFFFn"); - } - - { - let bm: BigInt = 0u64.into(); - let x = format!("{bm:?}"); - assert_eq!(x, "0n"); - } - - { - let bm: BigInt = 0i64.into(); - let x = format!("{bm:?}"); - assert_eq!(x, "0n"); - } -} - -fn eq_container(a: T, b: T, e: fn(a: &T::Item, &T::Item) -> bool) -> bool { - a.into_iter().eq_by_(b.into_iter(), e) -} - -fn eq_value(a: &Any, b: &Any) -> bool { - match (a.clone().into(), b.clone().into()) { - (Unpacked::Nullish(a), Unpacked::Nullish(b)) => a == b, - (Unpacked::Boolean(a), Unpacked::Boolean(b)) => a == b, - (Unpacked::Number(a), Unpacked::Number(b)) => a.to_bits() == b.to_bits(), - (Unpacked::String(a), Unpacked::String(b)) => a == b, - (Unpacked::BigInt(a), Unpacked::BigInt(b)) => a == b, - (Unpacked::Array(a), Unpacked::Array(b)) => eq_container(a, b, eq_value), - (Unpacked::Object(a), Unpacked::Object(b)) => { - eq_container(a, b, |x: &Property, y: &Property| { - x.0 == y.0 && eq_value(&x.1, &y.1) - }) - } - _ => false, - } -} - -// We keep old_eq here despite the fact that it's mostly redundant (most likely). At a better moment -// we will revisit this test and remove redundant cases here. -fn old_eq() { - // nullish - let null0: Any = Nullish::Null.to_any(); - let null1 = Nullish::Null.to_any(); - let undefined0 = Nullish::Undefined.to_any(); - let undefined1 = Nullish::Undefined.to_any(); - { - assert_eq!(null0, null1); - assert_eq!(undefined0, undefined1); - assert_ne!(null1, undefined0); - } - // boolean - let true0: Any = true.to_any(); - let true1 = true.to_any(); - let false0 = false.to_any(); - let false1 = false.to_any(); - { - // boolean - { - assert_eq!(true0, true1); - assert_eq!(false0, false1); - assert_ne!(true0, false0); - } - // nullish - { - assert_ne!(false0, undefined0); - assert_ne!(false0, null0); - } - } - // number - let number00: Any = 2.3.to_any(); - let number01 = 2.3.to_any(); - let number1 = (-5.4).to_any(); - let number_nan = f64::NAN.to_any(); - let number_p0 = 0.0.to_any(); - let number_n0 = (-0.0).to_any(); - let number_p_inf0: Any = f64::INFINITY.to_any(); - let number_p_inf1 = f64::INFINITY.to_any(); - let number_n_inf0 = (-f64::INFINITY).to_any(); - let number_n_inf1 = (-f64::INFINITY).to_any(); - { - // number - { - assert_eq!(number00, number01); - assert_ne!(number00, number1); - assert_ne!(number_nan, number_nan); - assert_eq!(number_p0, number_n0); - // Object.is() - assert_eq!((-0f64).to_bits(), (-0f64).to_bits()); - assert_ne!(0f64.to_bits(), (-0f64).to_bits()); - assert_eq!(number_p_inf0, number_p_inf1); - assert_eq!(number_n_inf0, number_n_inf1); - assert_ne!(number_p_inf0, number_n_inf0); - } - // nullish - { - assert_ne!(number_nan, undefined0); - assert_ne!(number00, undefined0); - } - } - // string - let string_hello0: Any = "Hello!".into(); - let string_hello1 = "Hello!".into(); - let string_world0 = "world!".into(); - let string0: Any = "0".into(); - let s0: String = "0".into(); - { - { - assert_eq!(string_hello0, string_hello1); - assert_ne!(string_hello0, string_world0); - } - { - assert_ne!(number_p0, string0.clone()); - } - } - // bigint - let bigint12_0: Any = Into::>::into(12u64).to_any(); - let bigint12_1 = Into::>::into(12u64).to_any(); - let bigint12m = Into::>::into(-12i64).to_any(); - let bigint13 = Into::>::into(13u64).to_any(); - { - assert_eq!(bigint12_0, bigint12_1); - assert_ne!(bigint12_0, bigint12m); - assert_ne!(bigint12_0, bigint13); - } - // array - let array0: Any = Array::default().to_any(); - let array1 = Array::default().to_any(); - let array2: Any = [string0.clone()].to_array().to_any(); - { - assert_eq!(array0, array0); - assert_ne!(array0, array1); - assert_eq!(array2, array2); - } - // object - let object0: Any = [(s0.clone(), string0.clone())].to_object().to_any(); - let object1 = [(s0, string0)].to_object().to_any(); - { - assert_eq!(object0, object0); - assert_ne!(object0, object1); - } -} - -fn serialization() { - use std::io::Cursor; - - let values: &[Any] = &[ - Nullish::Null.to_any(), - Nullish::Undefined.to_any(), - true.to_any(), - false.to_any(), - 2.3.to_any(), - "Hello".into(), - Into::>::into(12u64).to_any(), - Array::default().to_any(), - [7.0.to_any()].to_array().to_any(), - [("a".into(), 1.0.to_any()), ("b".into(), "c".into())] - .to_object() - .to_any(), - ]; - - for value in values.into_iter() { - let mut buf = Vec::new(); - value.clone().serialize(&mut buf).unwrap(); - let mut cursor = Cursor::new(buf); - let result = Any::deserialize(&mut cursor).unwrap(); - assert!(eq_value(&value, &result)); - } -} - -fn number_coerce_to_string() { - let n: Any = 123.0.to_any(); - assert_eq!(n.to_string(), Ok("123".into())); - - let n: Any = (-456.0).to_any(); - assert_eq!(n.to_string(), Ok("-456".into())); - - let n: Any = (0.0).to_any(); - assert_eq!(n.to_string(), Ok("0".into())); - - let n: Any = (-0.0).to_any(); - assert_eq!(n.to_string(), Ok("0".into())); - - let n: Any = (1.0 / -0.0).to_any(); - assert_eq!(n.to_string(), Ok("-Infinity".into())); - - let n: Any = f64::INFINITY.to_any(); - assert_eq!(n.to_string(), Ok("Infinity".into())); - - let n: Any = f64::NEG_INFINITY.to_any(); - assert_eq!(n.to_string(), Ok("-Infinity".into())); - - let n: Any = f64::NAN.to_any(); - assert_eq!(n.to_string(), Ok("NaN".into())); -} - -fn array_coerce_to_string() { - let a: Any = [].to_array().to_any(); - assert_eq!(a.to_string(), Ok("".into())); - - let a: Any = [1.0.to_any()].to_array().to_any(); - assert_eq!(a.to_string(), Ok("1".into())); - - let a: Any = [1.0.to_any(), 2.0.to_any(), 3.0.to_any()] - .to_array() - .to_any(); - assert_eq!(a.to_string(), Ok("1,2,3".into())); - - let a: Any = [ - 1.0.to_any(), - [2.0.to_any(), 3.0.to_any()].to_array().to_any(), - 4.0.to_any(), - ] - .to_array() - .to_any(); - assert_eq!(a.to_string(), Ok("1,2,3,4".into())); -} - -fn format_fn() { - let f = Function::(A::InternalFunction::new_ok( - ("myfunc".into(), 2), - [0xDE, 0xAD, 0xBE, 0xEF], - )); - let x = format!("{f:?}"); - assert_eq!(x, "function myfunc(a0,a1) {DEADBEEF}"); -} - -fn assert_is_nan(a: Any, test_case: &str) { - let nan = Any::unary_plus(a).unwrap(); - let f = nan.to_number().expect(test_case); - assert!(f.is_nan(), "{test_case}"); -} - -fn test_op(result: Any, expected: Any, test_case: &str) { - match expected.clone().into() { - Unpacked::Number(f) => { - if f.is_nan() { - assert_is_nan(result, test_case); - } else { - let res: f64 = result.try_into().unwrap(); - assert_eq!(f.to_bits(), res.to_bits(), "{test_case}"); - } - } - Unpacked::BigInt(_) => { - assert_eq!(result, expected); - } - _ => panic!("expected is neither Number nor BigInt in '{}'", test_case), - } -} - -fn unary_plus() { - { - let n: Any = Nullish::Null.to_any(); - assert_eq!(Any::unary_plus(n), Ok(0.0.to_any())); - } - { - let n: Any = Nullish::Undefined.to_any(); - let result = Any::unary_plus(n).unwrap(); - // Check that the result is NaN - let num: f64 = result.try_into().unwrap(); - assert!(num.is_nan()); - } - { - let n: Any = false.to_any(); - assert_eq!(Any::unary_plus(n), Ok(0.0.to_any())); - } - { - let n: Any = true.to_any(); - assert_eq!(Any::unary_plus(n), Ok(1.0.to_any())); - } - { - let n: Any = 0.0.to_any(); - assert_eq!(Any::unary_plus(n.clone()), Ok(n)); - } - { - let n: Any = (-239.0).to_any(); - assert_eq!(Any::unary_plus(n.clone()), Ok(n)); - } - { - let n: Any = f64::INFINITY.to_any(); - assert_eq!(Any::unary_plus(n.clone()), Ok(n)); - } - { - let n: Any = f64::NEG_INFINITY.to_any(); - assert_eq!(Any::unary_plus(n.clone()), Ok(n)); - } - { - let n: Any = f64::NAN.to_any(); - let result = Any::unary_plus(n).unwrap(); - // Check that the result is NaN - let num: f64 = result.try_into().unwrap(); - assert!(num.is_nan()); - } - - let n0 = 0.0.to_any::(); - let nan = f64::NAN.to_any::(); - let null = Nullish::Null.to_any::(); - let test_cases: &[(Any, Any, &str)] = &[ - (null.clone(), n0.clone(), "null"), - (Nullish::Undefined.to_any(), nan.clone(), "undefined"), - (true.to_any(), 1.0.to_any(), "boolean true"), - (false.to_any(), n0.clone(), "boolean false"), - (n0.clone(), 0.0.to_any(), "number 0"), - (2.3.to_any(), 2.3.to_any(), "number 2.3"), - ((-2.3).to_any(), (-2.3).to_any(), "number -2.3"), - ("".into(), n0.clone(), "string \"\""), - ("0".into(), n0.clone(), "string \"0\""), - ("2.3e2".into(), 2.3e2.to_any(), "string \"2.3e2\""), - ("a".into(), nan.clone(), "string \"a\""), - ([].to_array().to_any(), n0.clone(), "array []"), - ( - [(-0.3).to_any()].to_array().to_any(), - (-0.3).to_any(), - "array [-0.3]", - ), - ( - ["0.3".into()].to_array().to_any(), - 0.3.to_any(), - "array [\"0.3\"]", - ), - ( - [null.clone()].to_array().to_any(), - n0.clone(), - "array [null]", - ), - ( - [null.clone(), null.clone()].to_array().to_any(), - nan.clone(), - "array [null,null]", - ), - ([].to_object().to_any(), nan.clone(), "object {{}}"), - // TODO: decide on testing objects with valueOf, toString functions. - ( - Function::(A::InternalFunction::new_ok(("".into(), 0), [0])).to_any(), - nan.clone(), - "function", - ), - ]; - for (a, expected, test_case) in test_cases.iter() { - test_op::( - Any::unary_plus(a.clone()).unwrap(), - expected.clone(), - test_case, - ); - } - - // bigint - let b0: Any = BigInt::default().to_any(); - assert_eq!( - Any::unary_plus(b0), - Err("TypeError: Cannot convert a BigInt value to a number".into()) - ); -} - -fn unary_minus() { - let nan = f64::NAN.to_any::(); - let null = Nullish::Null.to_any::(); - let bi1: BigInt = 1u64.into(); - let bi_m1: BigInt = (-1i64).into(); - let test_cases: &[(Any, Any, &str)] = &[ - (null.clone(), (-0.0).to_any(), "null"), - (Nullish::Undefined.to_any(), nan.clone(), "undefined"), - (true.to_any(), (-1.0).to_any(), "boolean true"), - (false.to_any(), (-0.0).to_any(), "boolean false"), - (0.0.to_any::().clone(), (-0.0).to_any(), "number 0"), - ((-2.3).to_any(), 2.3.to_any(), "number -2.3"), - (2.3.to_any(), (-2.3).to_any(), "number 2.3"), - ("".into(), (-0.0).to_any(), "string \"\""), - ("0".into(), (-0.0).to_any(), "string \"0\""), - ("2.3e2".into(), (-2.3e2).to_any(), "string \"2.3e2\""), - ("a".into(), nan.clone(), "string \"a\""), - ([].to_array().to_any(), (-0.0).to_any(), "array []"), - (bi1.to_any(), bi_m1.to_any(), "bigint 1n"), - ( - [(-0.3).to_any()].to_array().to_any(), - 0.3.to_any(), - "array [-0.3]", - ), - ( - [null.clone()].to_array().to_any(), - (-0.0).to_any(), - "array [null]", - ), - ( - [null.clone(), null.clone()].to_array().to_any(), - nan.clone(), - "array [null,null]", - ), - ( - ["0.3".into()].to_array().to_any(), - (-0.3).to_any(), - "array [\"0.3\"]", - ), - // TODO: decide on testing objects with valueOf, toString functions. - ( - Function::(A::InternalFunction::new_ok(("".into(), 0), [0])).to_any(), - nan.clone(), - "function", - ), - ]; - for (a, expected, test_case) in test_cases.iter() { - test_op::((-a.clone()).unwrap(), expected.clone(), test_case); - } -} - -fn mul() { - let n0: Any = 0.0.to_any(); - let n1: Any = 1.0.to_any(); - let n_minus1: Any = (-1.0).to_any(); - let n10: Any = 10.0.to_any(); - let n_minus10: Any = (-10.0).to_any(); - let nan: Any = f64::NAN.to_any(); - let null: Any = Nullish::Null.to_any(); - let true_: Any = true.to_any(); - let false_: Any = false.to_any(); - let bi0: Any = BigInt::default().to_any(); - let bi1: Any = Into::>::into(1u64).to_any(); - let bi_minus1: Any = Into::>::into(-1i64).to_any(); - let bi10: Any = Into::>::into(10u64).to_any(); - let bi_minus10: Any = Into::>::into(-10i64).to_any(); - let test_cases: &[(Any, Any, Any, &str)] = &[ - (null.clone(), null.clone(), n0.clone(), "null by null"), - (null.clone(), n0.clone(), n0.clone(), "null by 0"), - ( - Nullish::Undefined.to_any(), - n0.clone(), - nan.clone(), - "undefined by 0", - ), - (true_.clone(), n0.clone(), n0.clone(), "boolean true by 0"), - (true_.clone(), n1.clone(), n1.clone(), "boolean true by 1"), - ( - true_.clone(), - n10.clone(), - n10.clone(), - "boolean true by 10", - ), - (false_.clone(), n0.clone(), n0.clone(), "boolean false by 0"), - (false_.clone(), n1.clone(), n0.clone(), "boolean false by 1"), - ( - false_.clone(), - n10.clone(), - n0.clone(), - "boolean false by 10", - ), - (n0.clone(), n0.clone(), n0.clone(), "0 by 0"), - (n0.clone(), n1.clone(), n0.clone(), "0 by 1"), - (n1.clone(), n1.clone(), n1.clone(), "1 by 1"), - (n1.clone(), n_minus1.clone(), n_minus1.clone(), "1 by -1"), - (n1.clone(), n10.clone(), n10.clone(), "1 by 10"), - (n_minus1.clone(), n10.clone(), n_minus10.clone(), "-1 by 10"), - (n10.clone(), n10.clone(), 100.0.to_any(), "10 by 10"), - ( - n_minus10.clone(), - n10.clone(), - (-100.0).to_any(), - "-10 by 10", - ), - (bi0.clone(), bi0.clone(), bi0.clone(), "0n by 0n"), - (bi0.clone(), bi1.clone(), bi0.clone(), "0n by 1n"), - (bi1.clone(), bi1.clone(), bi1.clone(), "1n by 1n"), - ( - bi1.clone(), - bi_minus1.clone(), - bi_minus1.clone(), - "1n by -1n", - ), - (bi1.clone(), bi10.clone(), bi10.clone(), "1n by 10n"), - ( - bi_minus1.clone(), - bi10.clone(), - Into::>::into(-10i64).to_any(), - "-1n by 10n", - ), - ( - bi10.clone(), - bi10.clone(), - Into::>::into(100i64).to_any(), - "10n by 10n", - ), - ( - bi_minus10.clone(), - bi10.clone(), - Into::>::into(-100i64).to_any(), - "-10n by 10n", - ), - ("".into(), n1.clone(), n0.clone(), "\"\" by 1"), - ("10".into(), n1.clone(), n10.clone(), "\"10\" by 1"), - ("a".into(), n1.clone(), nan.clone(), "\"a\" by 1"), - ("1n".into(), n1.clone(), nan.clone(), "\"1n\" by 1"), - ([].to_array().to_any(), n1.clone(), n0.clone(), "[] by 1"), - ( - [n10.clone()].to_array().to_any(), - n1.clone(), - n10.clone(), - "[10] by 1", - ), - ( - ["10".into()].to_array().to_any(), - n1.clone(), - n10.clone(), - "[\"10\"] by 1", - ), - ( - [n0.clone(), n0.clone()].to_array().to_any(), - n1.clone(), - nan.clone(), - "[\"0,0\"] by 1", - ), - ( - [].to_object().to_any(), - n1.clone(), - nan.clone(), - "{{}} by 1", - ), - // TODO: decide on testing objects with valueOf, toString functions. - ]; - for (a, b, expected, test_case) in test_cases.iter() { - test_op::( - (a.clone() * b.clone()).unwrap(), - expected.clone(), - test_case, - ); - test_op::( - (b.clone() * a.clone()).unwrap(), - expected.clone(), - test_case, - ); - } -} - -fn bigint_add() { - let n0: Any = BigInt::default().to_any(); - assert_eq!((n0.clone() + n0.clone()), n0); - let n2: Any = BigInt::from(2u64).to_any(); - let n4: Any = BigInt::from(4u64).to_any(); - assert_eq!((n0.clone() + n2.clone()), n2); - assert_eq!((n2.clone() + n4.clone()), BigInt::from(6u64).to_any()); -} - -fn bigint_mul() { - let n0: Any = BigInt::default().to_any(); - let n1: Any = BigInt::from(1u64).to_any(); - assert_eq!((n1.clone() * n0.clone()).unwrap(), n0); - assert_eq!((n0.clone() * n1.clone()).unwrap(), n0); - - let n_minus1: Any = BigInt::from(-1i64).to_any(); - assert_eq!((n_minus1.clone() * n0.clone()).unwrap(), n0); - assert_eq!((n0.clone() * n_minus1.clone()).unwrap(), n0); - assert_eq!((n_minus1.clone() * n_minus1.clone()).unwrap(), n1); - - let a: Any = BigInt::normalize_new(Sign::Positive, [1, 2, 3, 4]).to_any(); - let b: Any = BigInt::normalize_new(Sign::Positive, [5, 6, 7]).to_any(); - let expected: Any = BigInt::normalize_new(Sign::Positive, [5, 16, 34, 52, 45, 28]).to_any(); - assert_eq!((a.clone() * b.clone()).unwrap(), expected); - assert_eq!((b.clone() * a.clone()).unwrap(), expected); - - let a: Any = BigInt::normalize_new(Sign::Negative, [u64::MAX]).to_any(); - let expected: Any = BigInt::normalize_new(Sign::Positive, [1, u64::MAX - 1]).to_any(); - assert_eq!((a.clone() * a.clone()).unwrap(), expected); - - let b: Any = BigInt::normalize_new(Sign::Negative, [u64::MAX, u64::MAX, u64::MAX]).to_any(); - let expected: Any = - BigInt::normalize_new(Sign::Positive, [1, u64::MAX, u64::MAX, u64::MAX - 1]).to_any(); - assert_eq!((a.clone() * b.clone()).unwrap(), expected); - assert_eq!((b.clone() * a.clone()).unwrap(), expected); -} - -fn bigint_negative_zero() { - let mn0: BigInt = BigInt::normalize_new(Sign::Negative, []); - let n0: BigInt = BigInt::default(); - assert_eq!(mn0, n0); -} - -fn gen_test() { - nullish_eq::(); - bool_eq::(); - number_eq::(); - string_eq::(); - object_eq::(); - array_eq::(); - bigint_eq::(); - old_eq::(); - serialization::(); - number_coerce_to_string::(); - array_coerce_to_string::(); - unary_plus::(); - unary_minus::(); - mul::(); - bigint_add::(); - bigint_mul::(); - bigint_negative_zero::(); - // - format_fn::(); -} - -#[test] -fn test() { - gen_test::(); -} diff --git a/nanvm-lib/tests/test/generated.rs b/nanvm-lib/tests/test/generated.rs new file mode 100644 index 0000000000..1f714d52ba --- /dev/null +++ b/nanvm-lib/tests/test/generated.rs @@ -0,0 +1,210 @@ +// @generated by `npm run ci-update` from `fjs/nanvm/module.f.mjs`. +// Do not edit: change the shared operator test data and regenerate. + +use super::harness::*; + +#[rustfmt::skip] +fn eq() { + let empty_array: Any = Array::default().to_any(); + let string_array: Any = [string_any("0")].to_array().to_any(); + let object: Any = [(string_key("0"), string_any("0"))].to_object().to_any(); + check_eq::("nullByNull", Nullish::Null.to_any(), Nullish::Null.to_any(), true); + check_eq::("undefinedByUndefined", Nullish::Undefined.to_any(), Nullish::Undefined.to_any(), true); + check_eq::("nullByUndefined", Nullish::Null.to_any(), Nullish::Undefined.to_any(), false); + check_eq::("trueByTrue", true.to_any(), true.to_any(), true); + check_eq::("falseByFalse", false.to_any(), false.to_any(), true); + check_eq::("trueByFalse", true.to_any(), false.to_any(), false); + check_eq::("falseByUndefined", false.to_any(), Nullish::Undefined.to_any(), false); + check_eq::("falseByNull", false.to_any(), Nullish::Null.to_any(), false); + check_eq::("numberBySameNumber", (2.3f64).to_any(), (2.3f64).to_any(), true); + check_eq::("numberByOtherNumber", (2.3f64).to_any(), (-5.4f64).to_any(), false); + check_eq::("nanByNan", (f64::NAN).to_any(), (f64::NAN).to_any(), false); + check_eq::("zeroByNegativeZero", (0f64).to_any(), (-0f64).to_any(), true); + check_eq::("infinityByInfinity", (f64::INFINITY).to_any(), (f64::INFINITY).to_any(), true); + check_eq::("negativeInfinityByNegativeInfinity", (f64::NEG_INFINITY).to_any(), (f64::NEG_INFINITY).to_any(), true); + check_eq::("infinityByNegativeInfinity", (f64::INFINITY).to_any(), (f64::NEG_INFINITY).to_any(), false); + check_eq::("undefinedByNan", Nullish::Undefined.to_any(), (f64::NAN).to_any(), false); + check_eq::("undefinedByZero", Nullish::Undefined.to_any(), (0f64).to_any(), false); + check_eq::("stringBySameString", string_any("hello"), string_any("hello"), true); + check_eq::("stringByOtherString", string_any("hello"), string_any("world"), false); + check_eq::("zeroByStringZero", (0f64).to_any(), string_any("0"), false); + check_eq::("bigintBySameBigint", bigint_any(12), bigint_any(12), true); + check_eq::("bigintByNegatedBigint", bigint_any(12), bigint_any(-12), false); + check_eq::("bigintByOtherBigint", bigint_any(12), bigint_any(13), false); + check_eq::("twelveByStringTwelve", bigint_any(12), string_any("12"), false); + check_eq::("arrayByItself", empty_array.clone(), empty_array.clone(), true); + check_eq::("arrayByEqualArray", Array::default().to_any(), Array::default().to_any(), false); + check_eq::("stringArrayByItself", string_array.clone(), string_array.clone(), true); + check_eq::("objectByItself", object.clone(), object.clone(), true); + check_eq::("objectByEqualObject", object.clone(), [(string_key("0"), string_any("0"))].to_object().to_any(), false); +} + +#[rustfmt::skip] +fn unary_plus() { + check::("null", Any::unary_plus(Nullish::Null.to_any()), (0f64).to_any()); + check::("undefined", Any::unary_plus(Nullish::Undefined.to_any()), (f64::NAN).to_any()); + check::("booleanFalse", Any::unary_plus(false.to_any()), (0f64).to_any()); + check::("booleanTrue", Any::unary_plus(true.to_any()), (1f64).to_any()); + check::("numberZero", Any::unary_plus((0f64).to_any()), (0f64).to_any()); + check::("numberPositive", Any::unary_plus((2.3f64).to_any()), (2.3f64).to_any()); + check::("numberNegative", Any::unary_plus((-2.3f64).to_any()), (-2.3f64).to_any()); + check::("numberLarge", Any::unary_plus((-239f64).to_any()), (-239f64).to_any()); + check::("numberInfinity", Any::unary_plus((f64::INFINITY).to_any()), (f64::INFINITY).to_any()); + check::("numberNegativeInfinity", Any::unary_plus((f64::NEG_INFINITY).to_any()), (f64::NEG_INFINITY).to_any()); + check::("numberNan", Any::unary_plus((f64::NAN).to_any()), (f64::NAN).to_any()); + check::("stringEmpty", Any::unary_plus(string_any("")), (0f64).to_any()); + check::("stringZero", Any::unary_plus(string_any("0")), (0f64).to_any()); + check::("stringNumber", Any::unary_plus(string_any("2.3")), (2.3f64).to_any()); + check::("stringExponent", Any::unary_plus(string_any("2.3e2")), (230f64).to_any()); + check::("stringNotANumber", Any::unary_plus(string_any("a")), (f64::NAN).to_any()); + check::("arrayEmpty", Any::unary_plus(Array::default().to_any()), (0f64).to_any()); + check::("arrayNumber", Any::unary_plus([(2.3f64).to_any()].to_array().to_any()), (2.3f64).to_any()); + check::("arrayNegativeNumber", Any::unary_plus([(-0.3f64).to_any()].to_array().to_any()), (-0.3f64).to_any()); + check::("arrayString", Any::unary_plus([string_any("-2.3")].to_array().to_any()), (-2.3f64).to_any()); + check::("arrayPositiveString", Any::unary_plus([string_any("0.3")].to_array().to_any()), (0.3f64).to_any()); + check::("arrayNull", Any::unary_plus([Nullish::Null.to_any()].to_array().to_any()), (0f64).to_any()); + check::("arrayPair", Any::unary_plus([Nullish::Null.to_any(), Nullish::Null.to_any()].to_array().to_any()), (f64::NAN).to_any()); + check::("objectEmpty", Any::unary_plus(Object::default().to_any()), (f64::NAN).to_any()); + check::("function", Any::unary_plus(function_any()), (f64::NAN).to_any()); + check_throws::("bigint", Any::unary_plus(bigint_any(0))); +} + +#[rustfmt::skip] +fn unary_minus() { + check::("null", -(Nullish::Null.to_any()), (-0f64).to_any()); + check::("undefined", -(Nullish::Undefined.to_any()), (f64::NAN).to_any()); + check::("booleanFalse", -(false.to_any()), (-0f64).to_any()); + check::("booleanTrue", -(true.to_any()), (-1f64).to_any()); + check::("numberZero", -((0f64).to_any()), (-0f64).to_any()); + check::("numberPositive", -((2.3f64).to_any()), (-2.3f64).to_any()); + check::("numberNegative", -((-2.3f64).to_any()), (2.3f64).to_any()); + check::("numberLarge", -((-239f64).to_any()), (239f64).to_any()); + check::("numberInfinity", -((f64::INFINITY).to_any()), (f64::NEG_INFINITY).to_any()); + check::("numberNegativeInfinity", -((f64::NEG_INFINITY).to_any()), (f64::INFINITY).to_any()); + check::("numberNan", -((f64::NAN).to_any()), (f64::NAN).to_any()); + check::("stringEmpty", -(string_any("")), (-0f64).to_any()); + check::("stringZero", -(string_any("0")), (-0f64).to_any()); + check::("stringNumber", -(string_any("2.3")), (-2.3f64).to_any()); + check::("stringExponent", -(string_any("2.3e2")), (-230f64).to_any()); + check::("stringNotANumber", -(string_any("a")), (f64::NAN).to_any()); + check::("arrayEmpty", -(Array::default().to_any()), (-0f64).to_any()); + check::("arrayNumber", -([(2.3f64).to_any()].to_array().to_any()), (-2.3f64).to_any()); + check::("arrayNegativeNumber", -([(-0.3f64).to_any()].to_array().to_any()), (0.3f64).to_any()); + check::("arrayString", -([string_any("-2.3")].to_array().to_any()), (2.3f64).to_any()); + check::("arrayPositiveString", -([string_any("0.3")].to_array().to_any()), (-0.3f64).to_any()); + check::("arrayNull", -([Nullish::Null.to_any()].to_array().to_any()), (-0f64).to_any()); + check::("arrayPair", -([Nullish::Null.to_any(), Nullish::Null.to_any()].to_array().to_any()), (f64::NAN).to_any()); + check::("objectEmpty", -(Object::default().to_any()), (f64::NAN).to_any()); + check::("function", -(function_any()), (f64::NAN).to_any()); + check::("bigintPositive", -(bigint_any(1)), bigint_any(-1)); + check::("bigintNegative", -(bigint_any(-1)), bigint_any(1)); +} + +#[rustfmt::skip] +fn mul() { + check::("nullByNull", Nullish::Null.to_any() * Nullish::Null.to_any(), (0f64).to_any()); + check::("nullByNullSwapped", Nullish::Null.to_any() * Nullish::Null.to_any(), (0f64).to_any()); + check::("nullByZero", Nullish::Null.to_any() * (0f64).to_any(), (0f64).to_any()); + check::("nullByZeroSwapped", (0f64).to_any() * Nullish::Null.to_any(), (0f64).to_any()); + check::("undefinedByZero", Nullish::Undefined.to_any() * (0f64).to_any(), (f64::NAN).to_any()); + check::("undefinedByZeroSwapped", (0f64).to_any() * Nullish::Undefined.to_any(), (f64::NAN).to_any()); + check::("trueByZero", true.to_any() * (0f64).to_any(), (0f64).to_any()); + check::("trueByZeroSwapped", (0f64).to_any() * true.to_any(), (0f64).to_any()); + check::("trueByOne", true.to_any() * (1f64).to_any(), (1f64).to_any()); + check::("trueByOneSwapped", (1f64).to_any() * true.to_any(), (1f64).to_any()); + check::("trueByTen", true.to_any() * (10f64).to_any(), (10f64).to_any()); + check::("trueByTenSwapped", (10f64).to_any() * true.to_any(), (10f64).to_any()); + check::("falseByZero", false.to_any() * (0f64).to_any(), (0f64).to_any()); + check::("falseByZeroSwapped", (0f64).to_any() * false.to_any(), (0f64).to_any()); + check::("falseByOne", false.to_any() * (1f64).to_any(), (0f64).to_any()); + check::("falseByOneSwapped", (1f64).to_any() * false.to_any(), (0f64).to_any()); + check::("falseByTen", false.to_any() * (10f64).to_any(), (0f64).to_any()); + check::("falseByTenSwapped", (10f64).to_any() * false.to_any(), (0f64).to_any()); + check::("zeroByZero", (0f64).to_any() * (0f64).to_any(), (0f64).to_any()); + check::("zeroByZeroSwapped", (0f64).to_any() * (0f64).to_any(), (0f64).to_any()); + check::("zeroByOne", (0f64).to_any() * (1f64).to_any(), (0f64).to_any()); + check::("zeroByOneSwapped", (1f64).to_any() * (0f64).to_any(), (0f64).to_any()); + check::("oneByOne", (1f64).to_any() * (1f64).to_any(), (1f64).to_any()); + check::("oneByOneSwapped", (1f64).to_any() * (1f64).to_any(), (1f64).to_any()); + check::("oneByMinusOne", (1f64).to_any() * (-1f64).to_any(), (-1f64).to_any()); + check::("oneByMinusOneSwapped", (-1f64).to_any() * (1f64).to_any(), (-1f64).to_any()); + check::("oneByTen", (1f64).to_any() * (10f64).to_any(), (10f64).to_any()); + check::("oneByTenSwapped", (10f64).to_any() * (1f64).to_any(), (10f64).to_any()); + check::("minusOneByTen", (-1f64).to_any() * (10f64).to_any(), (-10f64).to_any()); + check::("minusOneByTenSwapped", (10f64).to_any() * (-1f64).to_any(), (-10f64).to_any()); + check::("tenByTen", (10f64).to_any() * (10f64).to_any(), (100f64).to_any()); + check::("tenByTenSwapped", (10f64).to_any() * (10f64).to_any(), (100f64).to_any()); + check::("minusTenByTen", (-10f64).to_any() * (10f64).to_any(), (-100f64).to_any()); + check::("minusTenByTenSwapped", (10f64).to_any() * (-10f64).to_any(), (-100f64).to_any()); + check::("bigZeroByZero", bigint_any(0) * bigint_any(0), bigint_any(0)); + check::("bigZeroByZeroSwapped", bigint_any(0) * bigint_any(0), bigint_any(0)); + check::("bigZeroByOne", bigint_any(0) * bigint_any(1), bigint_any(0)); + check::("bigZeroByOneSwapped", bigint_any(1) * bigint_any(0), bigint_any(0)); + check::("bigOneByOne", bigint_any(1) * bigint_any(1), bigint_any(1)); + check::("bigOneByOneSwapped", bigint_any(1) * bigint_any(1), bigint_any(1)); + check::("bigOneByMinusOne", bigint_any(1) * bigint_any(-1), bigint_any(-1)); + check::("bigOneByMinusOneSwapped", bigint_any(-1) * bigint_any(1), bigint_any(-1)); + check::("bigOneByTen", bigint_any(1) * bigint_any(10), bigint_any(10)); + check::("bigOneByTenSwapped", bigint_any(10) * bigint_any(1), bigint_any(10)); + check::("bigMinusOneByTen", bigint_any(-1) * bigint_any(10), bigint_any(-10)); + check::("bigMinusOneByTenSwapped", bigint_any(10) * bigint_any(-1), bigint_any(-10)); + check::("bigTenByTen", bigint_any(10) * bigint_any(10), bigint_any(100)); + check::("bigTenByTenSwapped", bigint_any(10) * bigint_any(10), bigint_any(100)); + check::("bigMinusTenByTen", bigint_any(-10) * bigint_any(10), bigint_any(-100)); + check::("bigMinusTenByTenSwapped", bigint_any(10) * bigint_any(-10), bigint_any(-100)); + check::("emptyStringByOne", string_any("") * (1f64).to_any(), (0f64).to_any()); + check::("emptyStringByOneSwapped", (1f64).to_any() * string_any(""), (0f64).to_any()); + check::("stringTenByOne", string_any("10") * (1f64).to_any(), (10f64).to_any()); + check::("stringTenByOneSwapped", (1f64).to_any() * string_any("10"), (10f64).to_any()); + check::("stringLetterByOne", string_any("a") * (1f64).to_any(), (f64::NAN).to_any()); + check::("stringLetterByOneSwapped", (1f64).to_any() * string_any("a"), (f64::NAN).to_any()); + check::("stringBigintByOne", string_any("1n") * (1f64).to_any(), (f64::NAN).to_any()); + check::("stringBigintByOneSwapped", (1f64).to_any() * string_any("1n"), (f64::NAN).to_any()); + check::("emptyArrayByOne", Array::default().to_any() * (1f64).to_any(), (0f64).to_any()); + check::("emptyArrayByOneSwapped", (1f64).to_any() * Array::default().to_any(), (0f64).to_any()); + check::("arrayTenByOne", [(10f64).to_any()].to_array().to_any() * (1f64).to_any(), (10f64).to_any()); + check::("arrayTenByOneSwapped", (1f64).to_any() * [(10f64).to_any()].to_array().to_any(), (10f64).to_any()); + check::("arrayStringTenByOne", [string_any("10")].to_array().to_any() * (1f64).to_any(), (10f64).to_any()); + check::("arrayStringTenByOneSwapped", (1f64).to_any() * [string_any("10")].to_array().to_any(), (10f64).to_any()); + check::("arrayPairByOne", [(0f64).to_any(), (0f64).to_any()].to_array().to_any() * (1f64).to_any(), (f64::NAN).to_any()); + check::("arrayPairByOneSwapped", (1f64).to_any() * [(0f64).to_any(), (0f64).to_any()].to_array().to_any(), (f64::NAN).to_any()); + check::("emptyObjectByOne", Object::default().to_any() * (1f64).to_any(), (f64::NAN).to_any()); + check::("emptyObjectByOneSwapped", (1f64).to_any() * Object::default().to_any(), (f64::NAN).to_any()); + check_throws::("numberByBigint", (1f64).to_any() * bigint_any(1)); + check_throws::("numberByBigintSwapped", bigint_any(1) * (1f64).to_any()); +} + +#[rustfmt::skip] +fn string_coercion() { + check::("number", (123f64).to_any().to_string().map(|v| v.to_any()), string_any("123")); + check::("negativeNumber", (-456f64).to_any().to_string().map(|v| v.to_any()), string_any("-456")); + check::("zero", (0f64).to_any().to_string().map(|v| v.to_any()), string_any("0")); + check::("negativeZero", (-0f64).to_any().to_string().map(|v| v.to_any()), string_any("0")); + check::("infinity", (f64::INFINITY).to_any().to_string().map(|v| v.to_any()), string_any("Infinity")); + check::("negativeInfinity", (f64::NEG_INFINITY).to_any().to_string().map(|v| v.to_any()), string_any("-Infinity")); + check::("nan", (f64::NAN).to_any().to_string().map(|v| v.to_any()), string_any("NaN")); + check::("booleanTrue", true.to_any().to_string().map(|v| v.to_any()), string_any("true")); + check::("booleanFalse", false.to_any().to_string().map(|v| v.to_any()), string_any("false")); + check::("null", Nullish::Null.to_any().to_string().map(|v| v.to_any()), string_any("null")); + check::("undefined", Nullish::Undefined.to_any().to_string().map(|v| v.to_any()), string_any("undefined")); + check::("string", string_any("already").to_string().map(|v| v.to_any()), string_any("already")); + // TODO: nanvm-lib prints bigints in hexadecimal; see nanvm-lib/todo/bigint-decimal-string-coercion.md + // check::("bigint", bigint_any(123).to_string().map(|v| v.to_any()), string_any("123")); + // TODO: nanvm-lib prints bigints in hexadecimal; see nanvm-lib/todo/bigint-decimal-string-coercion.md + // check::("negativeBigint", bigint_any(-456).to_string().map(|v| v.to_any()), string_any("-456")); + check::("emptyArray", Array::default().to_any().to_string().map(|v| v.to_any()), string_any("")); + check::("singletonArray", [(1f64).to_any()].to_array().to_any().to_string().map(|v| v.to_any()), string_any("1")); + check::("array", [(1f64).to_any(), (2f64).to_any(), (3f64).to_any()].to_array().to_any().to_string().map(|v| v.to_any()), string_any("1,2,3")); + check::("nestedArray", [(1f64).to_any(), [(2f64).to_any(), (3f64).to_any()].to_array().to_any(), (4f64).to_any()].to_array().to_any().to_string().map(|v| v.to_any()), string_any("1,2,3,4")); + check::("arrayWithNullish", [Nullish::Null.to_any(), Nullish::Undefined.to_any(), (1f64).to_any()].to_array().to_any().to_string().map(|v| v.to_any()), string_any(",,1")); + check::("emptyObject", Object::default().to_any().to_string().map(|v| v.to_any()), string_any("[object Object]")); + check::("object", [(string_key("a"), (1f64).to_any())].to_object().to_any().to_string().map(|v| v.to_any()), string_any("[object Object]")); +} + +pub fn all() { + eq::(); + unary_plus::(); + unary_minus::(); + mul::(); + string_coercion::(); +} diff --git a/nanvm-lib/tests/test/harness.rs b/nanvm-lib/tests/test/harness.rs new file mode 100644 index 0000000000..6dfd82db9c --- /dev/null +++ b/nanvm-lib/tests/test/harness.rs @@ -0,0 +1,75 @@ +//! Hand-written support for the generated operator tests. +//! +//! `generated.rs` contains one statement per case and nothing else; every +//! value constructor and every assertion it uses lives here, so the printer in +//! `fjs/nanvm/rust/module.f.mjs` only has to name them. Re-exports at the top are +//! what the generated file's `use super::harness::*;` pulls in. + +pub use nanvm_lib::vm::{Any, Array, IVm, Nullish, Object, ToAny, ToArray, ToObject}; + +use nanvm_lib::vm::{BigInt, Function, IContainer, String, Unpacked}; + +/// An `Any` holding the string `v`. +pub fn string_any(v: &str) -> Any { + v.into() +} + +/// An object property key. +pub fn string_key(v: &str) -> String { + v.into() +} + +/// An `Any` holding the bigint `v`. +pub fn bigint_any(v: i64) -> Any { + Into::>::into(v).to_any() +} + +/// An `Any` holding a function. +/// +/// Which function does not matter: every operator covered by the shared data +/// coerces a function through `ToPrimitive`, which never inspects its body. +pub fn function_any() -> Any { + Function::(A::InternalFunction::new_ok(("".into(), 0), [0])).to_any() +} + +/// `Object.is`, the comparison the shared data's expectations are written in: +/// `NaN` matches `NaN`, and `0` does not match `-0`. +/// +/// `==` on `Any` is JavaScript's `===`, which gets both of those backwards, so +/// numbers are compared by their bits instead. +fn same(a: &Any, b: &Any) -> bool { + match (a.clone().into(), b.clone().into()) { + (Unpacked::Number(x), Unpacked::Number(y)) => { + if x.is_nan() || y.is_nan() { + x.is_nan() && y.is_nan() + } else { + x.to_bits() == y.to_bits() + } + } + _ => a == b, + } +} + +/// Checks that an operator returned `expected`. +pub fn check(case: &str, result: Result, Any>, expected: Any) { + match result { + Ok(v) => assert!(same(&v, &expected), "{case}: {v:?} is not {expected:?}"), + Err(e) => panic!("{case}: unexpected throw: {e:?}"), + } +} + +/// Checks that an operator threw. +/// +/// The thrown value is engine-specific, so the shared data does not describe +/// it and nothing here asserts on it. +pub fn check_throws(case: &str, result: Result, Any>) { + if let Ok(v) = result { + panic!("{case}: expected a throw, got {v:?}"); + } +} + +/// Checks strict equality (`===`) both ways round. +pub fn check_eq(case: &str, a: Any, b: Any, expected: bool) { + assert_eq!(a == b, expected, "{case}"); + assert_eq!(b == a, expected, "{case} reversed"); +} diff --git a/nanvm-lib/tests/test/main.rs b/nanvm-lib/tests/test/main.rs new file mode 100644 index 0000000000..8ccd0b208a --- /dev/null +++ b/nanvm-lib/tests/test/main.rs @@ -0,0 +1,253 @@ +//! Tests with no JavaScript counterpart. +//! +//! Operator behaviour is *not* tested here. It is described once, as data, in +//! `fjs/nanvm/module.f.mjs`, and reaches this crate as `generated.rs` +//! (see `nanvm-lib/tests/README.md`). What stays hand-written is everything +//! that has nothing to compare against in a JS engine: conversions out of +//! `Any`, `Debug` formatting, bigint limb arithmetic, and serialization. + +mod generated; +mod harness; + +use nanvm_lib::{ + common::{default::default, iter::Iter, serializable::Serializable}, + naive, + sign::Sign, + vm::{ + Any, Array, BigInt, Function, IContainer, IVm, Nullish, Object, Property, String, ToAny, + ToArray, ToObject, Unpacked, + }, +}; + +/// `try_into` out of `Any`, for each type that supports it. +fn conversions() { + let n: Any = Nullish::Null.to_any(); + let n: Nullish = n.try_into().unwrap(); + assert_eq!(n, Nullish::Null); + + let t: Any = true.to_any(); + let t: bool = t.try_into().unwrap(); + assert!(t); + + let s: Any = "Hello".into(); + let s: String = s.try_into().unwrap(); + assert_eq!(s, String::from("Hello")); + + let nan: Any = f64::NAN.to_any(); + let nan: f64 = nan.try_into().unwrap(); + assert!(nan.is_nan()); + + let nz: Any = (-0.0).to_any(); + let nz: f64 = nz.try_into().unwrap(); + assert_eq!(format!("{nz}"), "-0"); + assert_eq!(1.0 / nz, -f64::INFINITY); + + // The generated tests compare numbers with `Object.is` semantics, which + // `harness::same` implements with `to_bits`; this is the property that + // makes that work. + assert_eq!((-0f64).to_bits(), (-0f64).to_bits()); + assert_ne!(0f64.to_bits(), (-0f64).to_bits()); +} + +fn debug_format() { + let s: Any = "Hello".into(); + let s: String = s.try_into().unwrap(); + assert_eq!(format!("{s:?}"), "\"Hello\""); + + let o: Any = Object::default().to_any(); + let o: Object = o.try_into().unwrap(); + assert_eq!(format!("{o:?}"), "{}"); + + let a: Any = Array::default().to_any(); + let a: Array = a.try_into().unwrap(); + assert_eq!(format!("{a:?}"), "[]"); + + let b: Any = BigInt::default().to_any(); + let b: BigInt = b.try_into().unwrap(); + assert_eq!(b, default()); + assert_eq!(format!("{b:?}"), "0n"); +} + +/// `Debug` for bigints at the edges of the one-limb range. +fn bigint_debug_format() { + { + let bm: BigInt = i64::MIN.into(); + let x = format!("{bm:?}"); + // 0123456789ABCDEF + assert_eq!(x, "-0x8000000000000000n"); + let i: i64 = i64::MIN; + let m = i.overflowing_neg().0 as u64; + assert_eq!(m, 0x8000000000000000); + } + + { + let bm: BigInt = (i64::MIN + 1).into(); + let x = format!("{bm:?}"); + // 0123456789ABCDEF + assert_eq!(x, "-0x7FFFFFFFFFFFFFFFn"); + let i: i64 = i64::MIN + 1; + let m = i.overflowing_neg().0 as u64; + assert_eq!(m, 0x7FFFFFFFFFFFFFFF); + } + + { + let bm: BigInt = i64::MAX.into(); + let x = format!("{bm:?}"); + // 0123456789ABCDEF + assert_eq!(x, "0x7FFFFFFFFFFFFFFFn"); + } + + { + let bm: BigInt = u64::MAX.into(); + let x = format!("{bm:?}"); + // 0123456789ABCDEF + assert_eq!(x, "0xFFFFFFFFFFFFFFFFn"); + } + + { + let bm: BigInt = 0u64.into(); + let x = format!("{bm:?}"); + assert_eq!(x, "0n"); + } + + { + let bm: BigInt = 0i64.into(); + let x = format!("{bm:?}"); + assert_eq!(x, "0n"); + } +} + +fn eq_container(a: T, b: T, e: fn(a: &T::Item, &T::Item) -> bool) -> bool { + a.into_iter().eq_by_(b.into_iter(), e) +} + +/// Structural equality, which `==` on `Any` deliberately is not: containers +/// compare by reference there, so a round-tripped value never equals its +/// original. +fn eq_value(a: &Any, b: &Any) -> bool { + match (a.clone().into(), b.clone().into()) { + (Unpacked::Nullish(a), Unpacked::Nullish(b)) => a == b, + (Unpacked::Boolean(a), Unpacked::Boolean(b)) => a == b, + (Unpacked::Number(a), Unpacked::Number(b)) => a.to_bits() == b.to_bits(), + (Unpacked::String(a), Unpacked::String(b)) => a == b, + (Unpacked::BigInt(a), Unpacked::BigInt(b)) => a == b, + (Unpacked::Array(a), Unpacked::Array(b)) => eq_container(a, b, eq_value), + (Unpacked::Object(a), Unpacked::Object(b)) => { + eq_container(a, b, |x: &Property, y: &Property| { + x.0 == y.0 && eq_value(&x.1, &y.1) + }) + } + _ => false, + } +} + +fn serialization() { + use std::io::Cursor; + + let values: &[Any] = &[ + Nullish::Null.to_any(), + Nullish::Undefined.to_any(), + true.to_any(), + false.to_any(), + 2.3.to_any(), + "Hello".into(), + Into::>::into(12u64).to_any(), + Array::default().to_any(), + [7.0.to_any()].to_array().to_any(), + [("a".into(), 1.0.to_any()), ("b".into(), "c".into())] + .to_object() + .to_any(), + ]; + + for value in values.iter() { + let mut buf = Vec::new(); + value.clone().serialize(&mut buf).unwrap(); + let mut cursor = Cursor::new(buf); + let result = Any::deserialize(&mut cursor).unwrap(); + assert!(eq_value(value, &result)); + } +} + +fn format_fn() { + let f = Function::(A::InternalFunction::new_ok( + ("myfunc".into(), 2), + [0xDE, 0xAD, 0xBE, 0xEF], + )); + let x = format!("{f:?}"); + assert_eq!(x, "function myfunc(a0,a1) {DEADBEEF}"); +} + +/// The generated `unary_plus` case only asserts *that* `+0n` throws; the +/// message is `nanvm-lib`'s own, so it is pinned here. +fn unary_plus_bigint_message() { + let b: Any = BigInt::default().to_any(); + assert_eq!( + Any::unary_plus(b), + Err("TypeError: Cannot convert a BigInt value to a number".into()) + ); +} + +fn bigint_add() { + let n0: Any = BigInt::default().to_any(); + assert_eq!((n0.clone() + n0.clone()), n0); + let n2: Any = BigInt::from(2u64).to_any(); + let n4: Any = BigInt::from(4u64).to_any(); + assert_eq!((n0.clone() + n2.clone()), n2); + assert_eq!((n2.clone() + n4.clone()), BigInt::from(6u64).to_any()); +} + +/// Multi-limb multiplication, which the shared data cannot reach: its bigints +/// all fit in an `i64`. +fn bigint_mul() { + let n0: Any = BigInt::default().to_any(); + let n1: Any = BigInt::from(1u64).to_any(); + assert_eq!((n1.clone() * n0.clone()).unwrap(), n0); + assert_eq!((n0.clone() * n1.clone()).unwrap(), n0); + + let n_minus1: Any = BigInt::from(-1i64).to_any(); + assert_eq!((n_minus1.clone() * n0.clone()).unwrap(), n0); + assert_eq!((n0.clone() * n_minus1.clone()).unwrap(), n0); + assert_eq!((n_minus1.clone() * n_minus1.clone()).unwrap(), n1); + + let a: Any = BigInt::normalize_new(Sign::Positive, [1, 2, 3, 4]).to_any(); + let b: Any = BigInt::normalize_new(Sign::Positive, [5, 6, 7]).to_any(); + let expected: Any = BigInt::normalize_new(Sign::Positive, [5, 16, 34, 52, 45, 28]).to_any(); + assert_eq!((a.clone() * b.clone()).unwrap(), expected); + assert_eq!((b.clone() * a.clone()).unwrap(), expected); + + let a: Any = BigInt::normalize_new(Sign::Negative, [u64::MAX]).to_any(); + let expected: Any = BigInt::normalize_new(Sign::Positive, [1, u64::MAX - 1]).to_any(); + assert_eq!((a.clone() * a.clone()).unwrap(), expected); + + let b: Any = BigInt::normalize_new(Sign::Negative, [u64::MAX, u64::MAX, u64::MAX]).to_any(); + let expected: Any = + BigInt::normalize_new(Sign::Positive, [1, u64::MAX, u64::MAX, u64::MAX - 1]).to_any(); + assert_eq!((a.clone() * b.clone()).unwrap(), expected); + assert_eq!((b.clone() * a.clone()).unwrap(), expected); +} + +/// A `nanvm-lib` normalization invariant: there is no negative zero bigint. +fn bigint_negative_zero() { + let mn0: BigInt = BigInt::normalize_new(Sign::Negative, []); + let n0: BigInt = BigInt::default(); + assert_eq!(mn0, n0); +} + +fn gen_test() { + generated::all::(); + // + conversions::(); + debug_format::(); + bigint_debug_format::(); + serialization::(); + unary_plus_bigint_message::(); + bigint_add::(); + bigint_mul::(); + bigint_negative_zero::(); + format_fn::(); +} + +#[test] +fn test() { + gen_test::(); +} diff --git a/nanvm-lib/todo/bigint-decimal-string-coercion.md b/nanvm-lib/todo/bigint-decimal-string-coercion.md new file mode 100644 index 0000000000..cd79016dc8 --- /dev/null +++ b/nanvm-lib/todo/bigint-decimal-string-coercion.md @@ -0,0 +1,54 @@ +## Coerce bigints to decimal strings + +**Priority:** P3 +**Status:** open + +### Problem + +`StringCoercion::bigint` (`src/vm/string_coercion.rs`) formats through `Debug`: + +```rust +fn bigint(self, v: BigInt) -> Self::Result { + // TODO: we should use different algorithm for large numbers. + to_result(&format!("{v:?}")) +} +``` + +`Debug` for `BigInt` prints hexadecimal with an `n` suffix, so `String(123n)` +returns `"0x7Bn"` where JavaScript returns `"123"`, and `String(-456n)` returns +`"-0x1C8n"` instead of `"-456"`. `ToString` on a bigint is +[decimal by specification](https://tc39.es/ecma262/#sec-numeric-types-bigint-tostring) +unless an explicit radix is passed to `BigInt.prototype.toString`. + +Two cases in the shared operator test data +([`fjs/nanvm/module.f.mjs`](../../fjs/nanvm/module.f.mjs), `stringCoercion`) +carry a `rust` reason pointing here and are therefore commented out in +`tests/test/generated.rs`. Deleting those two `rust` reasons and regenerating +is the acceptance test for this issue. + +### Proposal + +Convert the limb vector to decimal digits: repeatedly divide the magnitude by +the largest power of ten that fits in a limb (`10^19` for `u64`), emitting 19 +digits per step and zero-padding all but the most significant group, then +prefix `-` for a negative sign. That is O(n²) in the number of limbs, which is +the same complexity the existing `Debug` path has and is fine at the sizes the +VM sees today; a divide-and-conquer split is a later optimization, not a +blocker. + +`Debug` keeps its hexadecimal form — it is a developer-facing dump of the limb +representation, and the bigint formatting tests in +[`tests/test/main.rs`](../tests/test/main.rs) pin it deliberately. + +### Tasks + +- [ ] Add decimal conversion for `BigInt`. +- [ ] Use it from `StringCoercion::bigint`. +- [ ] Remove the two `rust` reasons from `stringCoercion` in the shared test + data and regenerate `tests/test/generated.rs`. + +### Related + +- [`nanvm-lib/tests/README.md`](../tests/README.md) — how the shared operator + test data records divergences like this one. +- [mvp-roadmap](./mvp-roadmap.md) — the operators task this belongs to. diff --git a/nanvm-lib/todo/bigint-operator-test-scaffolding.md b/nanvm-lib/todo/bigint-operator-test-scaffolding.md index e306fce821..5d439bb7d2 100644 --- a/nanvm-lib/todo/bigint-operator-test-scaffolding.md +++ b/nanvm-lib/todo/bigint-operator-test-scaffolding.md @@ -54,6 +54,7 @@ Either way, delete the per-file comment clones. ### Related -- [single-source-of-truth-for-operator-tests.md](./single-source-of-truth-for-operator-tests.md) - — cross-language (JS proof vs Rust test) duplication; this issue is the - intra-Rust helper duplication, a different site and mechanism. +- [`nanvm-lib/tests/README.md`](../tests/README.md) — the shared operator test + data, which removed the cross-language (JS proof vs Rust test) duplication; + this issue is the intra-Rust helper duplication, a different site and + mechanism. diff --git a/nanvm-lib/todo/mvp-roadmap.md b/nanvm-lib/todo/mvp-roadmap.md index 8c956aa9ad..b937ea8068 100644 --- a/nanvm-lib/todo/mvp-roadmap.md +++ b/nanvm-lib/todo/mvp-roadmap.md @@ -286,15 +286,17 @@ as a generic `Any` facility, post-MVP. fixture may use `.f.mjs`; it does not define the repository extension contract. See [fjs-nanvm-integration](../../todo/fjs-nanvm-integration.md). -- [ ] **Test generation for operators** — one test-data module drives both - the FJS proof (JS engine reference) and the generated Rust tests. - Implement **before** the operators task below, so every new operator is - tested once, not twice. Doubly important now: the shared operator layer - is what keeps the interpreter and the generated code in agreement. See - [single-source-of-truth-for-operator-tests](./single-source-of-truth-for-operator-tests.md). +- [x] **Test generation for operators** — one test-data module drives both + the FJS proof (JS engine reference) and the generated Rust tests, so + every new operator is tested once, not twice. Doubly important now: the + shared operator layer is what keeps the interpreter and the generated + code in agreement. See + [`nanvm-lib/tests/README.md`](../tests/README.md). - [ ] **Complete all basic FunctionalScript operators** (Rust), including the short-circuit operators `&&`, `||`, `??` (lazy evaluation, like `?:`). - Preceded by the test-generation task above. + Each operator arrives as cases in + [`fjs/nanvm/module.f.mjs`](../../fjs/nanvm/module.f.mjs), which is what + tests it on both sides. Current status: [operator tables in `nanvm-lib/README.md`](../README.md). Spec: [operators](../../todo/lang/2340-operators.md). - [ ] **Parser**, using [`fjs/bnf/`](../../fjs/bnf/README.md) (FJS). @@ -388,6 +390,6 @@ compiler-compatibility migration rather than a separate rewrite. walking-skeleton integration: the `.rs` output target and the harness. - [console-program](./console-program.md) — the self-hosted `nanvm` crate (post-MVP). -- [single-source-of-truth-for-operator-tests](./single-source-of-truth-for-operator-tests.md) - — test generation preceding the operators task. +- [`nanvm-lib/tests/README.md`](../tests/README.md) — the shared operator test + data driving both the FJS proof and the generated Rust tests. - [fs-vm-load-save](./fs-vm-load-save.md) — load/execute/save semantics. diff --git a/nanvm-lib/todo/single-source-of-truth-for-operator-tests.md b/nanvm-lib/todo/single-source-of-truth-for-operator-tests.md deleted file mode 100644 index c926ef53fc..0000000000 --- a/nanvm-lib/todo/single-source-of-truth-for-operator-tests.md +++ /dev/null @@ -1,31 +0,0 @@ -## Single source of truth for operator tests - -**Priority:** P1 -**Status:** open - -Part of the [MVP roadmap](./mvp-roadmap.md): implement before completing the -basic operators, so every new operator is tested once (in the shared test -data) instead of twice (FJS proof + Rust tests written by hand). - -### Problem - -Operator tests are written twice: - -- [`tests/proof.f.ts`](tests/proof.f.ts) — runs against a standard JS engine to prove JS semantics. -- [`tests/test.rs`](tests/test.rs) — runs the same operations against `nanvm-lib`. - -The two files diverge over time (mismatches documented in [`tests/README.md`](tests/README.md)). Every new operator requires updating both files manually. - -### Proposal - -Introduce `tests/module.f.ts` as the single source of truth — pure data describing inputs and expected outputs for each operator. - -1. **JS proof** — a thin `proof.f.ts` imports the test data and runs each case through native JS operators. -2. **Rust tests** — a small Node/Deno script (`gen-tests.ts`) reads `module.f.ts` and writes `test.rs`, wired into `npm run update`. - -### Tasks - -- [ ] Design the test case data schema in `tests/module.f.ts`. -- [ ] Migrate `eq`, `unary_plus`, `unary_minus`, `stringCoercion`, `mul` test cases. -- [ ] Write `proof.f.ts` as a thin consumer of `module.f.ts`. -- [ ] Write `gen-tests.ts` to emit `test.rs` from `module.f.ts`. diff --git a/package.json b/package.json index cfd6266cdb..6677d25268 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "tsc && node ./fjs/module.ts t", "cov": "node --test --experimental-test-coverage --test-coverage-include=**/module.f.ts --test-coverage-include=**/module.f.mjs", "start": "node ./fjs/module.ts", - "ci-update": "node ./fjs/module.ts ci", + "ci-update": "node ./fjs/module.ts ci && node ./fjs/module.ts r ./fjs/nanvm/update/module.f.mjs", "dev-update": "node ./fjs/module.ts r ./fjs/dev/update/module.f.mjs", "update": "npm run ci-update && npm install && deno install && bun install", "index-html": "node ./fjs/module.ts r ./fjs/website/module.f.mjs", diff --git a/todo/camel-case-proof-keys.md b/todo/camel-case-proof-keys.md index 2bcc9b5376..d2ed902ddd 100644 --- a/todo/camel-case-proof-keys.md +++ b/todo/camel-case-proof-keys.md @@ -1,4 +1,4 @@ -## camel-case-proof-keys. 48 `proof` test keys are snake_case +## camel-case-proof-keys. 42 `proof` test keys are snake_case **Priority:** P4 **Status:** open @@ -7,13 +7,12 @@ A `proof` object's keys are the test names the runner prints (`proof.historyStep.overDo()`), and the repository writes identifiers in -camelCase everywhere else. 48 keys across 7 files are snake_case instead: +camelCase everywhere else. 42 keys across 6 files are snake_case instead: | file | count | | --- | --- | | `fjs/sul/id/proof.f.mjs` | 18 | | `fjs/sul/level/hash/proof.f.mjs` | 11 | -| `nanvm-lib/tests/proof.f.ts` | 6 | | `fjs/sul/proof.f.mjs` | 5 | | `fjs/types/bit_vec/proof.f.ts` | 4 | | `fjs/types/prime_field/proof.f.mjs` | 3 | @@ -43,7 +42,7 @@ everywhere at once. ### Tasks -- [ ] Rename the keys in the seven files above. +- [ ] Rename the keys in the six files above. - [ ] Add the convention to `AGENTS.md`, noting the keyword/export exception. - [ ] `npx tsc` clean; `fjs t` passes (test names change, counts do not).