@particle-academy/fancy-conformance 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/dist/index.cjs +13 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -3
- package/dist/index.d.ts +2 -3
- package/dist/index.js +13 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schema/case-table.schema.json +5 -0
- package/suites/shared/value-equality/cases.json +524 -0
- package/suites/shared/value-equality/manifest.json +28 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.11.0
|
package/dist/index.cjs
CHANGED
|
@@ -129,7 +129,7 @@ function runTable(suiteId, impl, options) {
|
|
|
129
129
|
continue;
|
|
130
130
|
}
|
|
131
131
|
results.push(
|
|
132
|
-
equals(actual, c.expected) ? { id: c.id, title: c.title, status: "pass" } : { id: c.id, title: c.title, status: "fail", expected: c.expected, actual }
|
|
132
|
+
equals(actual, c.expected, toleranceFor(c)) ? { id: c.id, title: c.title, status: "pass" } : { id: c.id, title: c.title, status: "fail", expected: c.expected, actual }
|
|
133
133
|
);
|
|
134
134
|
}
|
|
135
135
|
const failed = results.filter((r) => r.status === "fail").length;
|
|
@@ -166,20 +166,29 @@ function preview(value) {
|
|
|
166
166
|
if (s === void 0) return String(value);
|
|
167
167
|
return s.length > 120 ? `${s.slice(0, 60)}\u2026${s.slice(-40)} (len ${s.length})` : s;
|
|
168
168
|
}
|
|
169
|
-
function
|
|
169
|
+
function toleranceFor(c) {
|
|
170
|
+
const tolerance = c.tolerance;
|
|
171
|
+
return typeof tolerance === "number" && Number.isFinite(tolerance) ? tolerance : void 0;
|
|
172
|
+
}
|
|
173
|
+
function deepEquals(a, b, tolerance) {
|
|
170
174
|
if (Object.is(a, b)) return true;
|
|
175
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
176
|
+
if (tolerance === void 0) return false;
|
|
177
|
+
const scale = Math.max(1, Math.abs(a), Math.abs(b));
|
|
178
|
+
return Math.abs(a - b) <= tolerance * scale;
|
|
179
|
+
}
|
|
171
180
|
if (typeof a !== typeof b) return false;
|
|
172
181
|
if (a === null || b === null) return false;
|
|
173
182
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
174
183
|
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
175
|
-
return a.every((v, i) => deepEquals(v, b[i]));
|
|
184
|
+
return a.every((v, i) => deepEquals(v, b[i], tolerance));
|
|
176
185
|
}
|
|
177
186
|
if (typeof a === "object") {
|
|
178
187
|
const ka = Object.keys(a).sort();
|
|
179
188
|
const kb = Object.keys(b).sort();
|
|
180
189
|
if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
|
|
181
190
|
return ka.every(
|
|
182
|
-
(k) => deepEquals(a[k], b[k])
|
|
191
|
+
(k) => deepEquals(a[k], b[k], tolerance)
|
|
183
192
|
);
|
|
184
193
|
}
|
|
185
194
|
return false;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { dirname, join, relative, resolve, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport type {\n CaseResult,\n ConformanceCase,\n Language,\n RunSummary,\n Suite,\n SuiteManifest,\n} from \"./types\";\n\nexport * from \"./types\";\n\n/**\n * The repository root, whether this is running from `dist/` in an installed\n * package or from `src/` in a checkout.\n *\n * Resolved by walking up to the directory that holds `suites/`, rather than by\n * a fixed `../..`. The two existing parity harnesses in this suite both\n * hard-coded a relative path to a sibling checkout (`../../holy-sheet/src/`),\n * which is why they work in exactly one directory layout and silently no-op\n * everywhere else. This package must not repeat that.\n */\nfunction packageRoot(): string {\n let dir = dirname(fileURLToPath(import.meta.url));\n\n for (let i = 0; i < 6; i++) {\n try {\n if (statSync(join(dir, \"suites\")).isDirectory()) {\n return dir;\n }\n } catch {\n // keep walking\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n\n throw new Error(\n \"fancy-conformance: could not locate the suites/ directory. \" +\n \"If you vendored this package, keep suites/ next to dist/.\",\n );\n}\n\n/** The suite collection's own version — the thing a runner must print. */\nexport function suiteVersion(): string {\n return readFileSync(join(packageRoot(), \"VERSION\"), \"utf8\").trim();\n}\n\n/** Every suite id present, e.g. `[\"shared/decimal\", \"shared/satisfies-range\", …]`. */\nexport function listSuites(): string[] {\n const root = join(packageRoot(), \"suites\");\n const found: string[] = [];\n\n const walk = (dir: string): void => {\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue;\n const child = join(dir, entry.name);\n try {\n statSync(join(child, \"manifest.json\"));\n found.push(relative(root, child).split(sep).join(\"/\"));\n } catch {\n walk(child);\n }\n }\n };\n\n walk(root);\n return found.sort();\n}\n\n/** Load one suite's manifest and cases. Throws rather than returning a partial. */\nexport function loadSuite(id: string): Suite {\n return loadSuiteFrom(packageRoot(), id);\n}\n\n/**\n * Load a suite from an explicit root.\n *\n * Exported so the load-time guards below can be tested against a throwaway\n * fixture tree, rather than a test re-implementing them. A guard asserted by a\n * copy of itself is the failure mode this whole repository exists to stop, and\n * it would be an embarrassing one to ship here.\n */\nexport function loadSuiteFrom(root: string, id: string): Suite {\n const dir = join(root, \"suites\", ...id.split(\"/\"));\n const manifest = JSON.parse(readFileSync(join(dir, \"manifest.json\"), \"utf8\")) as SuiteManifest;\n\n if (manifest.caseFormat !== \"table\") {\n throw new Error(\n `fancy-conformance: suite \"${id}\" uses caseFormat \"${manifest.caseFormat}\", ` +\n \"which loadSuite() does not read. Use the artifact runner in runners/.\",\n );\n }\n\n const table = JSON.parse(\n readFileSync(join(dir, manifest.cases ?? \"cases.json\"), \"utf8\"),\n ) as { cases: ConformanceCase[] };\n\n assertUsableCases(id, table.cases);\n\n return { manifest, cases: table.cases };\n}\n\n/**\n * Reject a case table that cannot do its job, at LOAD time.\n *\n * A skip with no reason, and a duplicate id, are both silent in every other\n * respect: the suite still loads, still reports green, and still covers less\n * than it appears to. That is the exact failure this repository exists to stop,\n * so it is a hard error here rather than a lint somewhere else.\n */\nfunction assertUsableCases(id: string, cases: ConformanceCase[]): void {\n const seen = new Set<string>();\n\n for (const c of cases) {\n if (seen.has(c.id)) {\n throw new Error(`fancy-conformance: suite \"${id}\" has duplicate case id \"${c.id}\".`);\n }\n seen.add(c.id);\n\n for (const [lang, reason] of Object.entries(c.skip ?? {})) {\n if (typeof reason !== \"string\" || reason.trim() === \"\") {\n throw new Error(\n `fancy-conformance: case \"${id}/${c.id}\" skips ${lang} with no reason. ` +\n \"A skip must say why, because every runner prints it.\",\n );\n }\n }\n }\n}\n\nexport interface RunOptions {\n /** Which language is under test — decides which `skip` entries apply. */\n language: Language;\n /**\n * Compare a produced value with the expected one. Defaults to a\n * canonicalising deep equality: object keys sorted, arrays order-sensitive.\n */\n equals?: (actual: unknown, expected: unknown) => boolean;\n}\n\n/**\n * Run one implementation against a table suite.\n *\n * `impl` receives the case and returns the value to compare. Throwing is a\n * failure, not a crash — a case that blows up is data about the implementation.\n */\nexport function runTable(\n suiteId: string,\n impl: (c: ConformanceCase) => unknown,\n options: RunOptions,\n): RunSummary {\n const { manifest, cases } = loadSuite(suiteId);\n const equals = options.equals ?? deepEquals;\n const results: CaseResult[] = [];\n\n for (const c of cases) {\n const reason = c.skip?.[options.language];\n if (reason !== undefined) {\n results.push({ id: c.id, title: c.title, status: \"skip\", reason });\n continue;\n }\n\n let actual: unknown;\n try {\n actual = impl(c);\n } catch (error) {\n results.push({\n id: c.id,\n title: c.title,\n status: \"fail\",\n expected: c.expected,\n actual: `threw: ${error instanceof Error ? error.message : String(error)}`,\n });\n continue;\n }\n\n results.push(\n equals(actual, c.expected)\n ? { id: c.id, title: c.title, status: \"pass\" }\n : { id: c.id, title: c.title, status: \"fail\", expected: c.expected, actual },\n );\n }\n\n const failed = results.filter((r) => r.status === \"fail\").length;\n\n return {\n suite: manifest.suite,\n language: options.language,\n suiteVersion: suiteVersion(),\n passed: results.filter((r) => r.status === \"pass\").length,\n failed,\n skipped: results.filter((r) => r.status === \"skip\").length,\n results,\n ok: failed === 0,\n };\n}\n\n/**\n * A summary a CI log can be read from — including every skip, by name and\n * reason.\n *\n * Skips are printed unconditionally and never folded into a count. \"3 skipped\"\n * in a log is indistinguishable from full coverage at a glance, which is how a\n * suite stops meaning anything without anyone deciding that it should.\n */\nexport function formatSummary(summary: RunSummary): string {\n const lines: string[] = [\n `${summary.suite} [${summary.language}] — fancy-conformance ${summary.suiteVersion}`,\n ` ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped`,\n ];\n\n for (const r of summary.results) {\n if (r.status === \"skip\") {\n lines.push(` SKIP ${r.id} — ${r.reason}`);\n }\n if (r.status === \"fail\") {\n lines.push(` FAIL ${r.id} ${r.title}`);\n lines.push(` expected: ${preview(r.expected)}`);\n lines.push(` actual: ${preview(r.actual)}`);\n }\n }\n\n return lines.join(\"\\n\");\n}\n\nfunction preview(value: unknown): string {\n const s = typeof value === \"string\" ? value : JSON.stringify(value);\n if (s === undefined) return String(value);\n return s.length > 120 ? `${s.slice(0, 60)}…${s.slice(-40)} (len ${s.length})` : s;\n}\n\n/** Order-sensitive for arrays, order-insensitive for object keys. */\nexport function deepEquals(a: unknown, b: unknown): boolean {\n if (Object.is(a, b)) return true;\n if (typeof a !== typeof b) return false;\n if (a === null || b === null) return false;\n\n if (Array.isArray(a) || Array.isArray(b)) {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;\n return a.every((v, i) => deepEquals(v, b[i]));\n }\n\n if (typeof a === \"object\") {\n const ka = Object.keys(a as object).sort();\n const kb = Object.keys(b as object).sort();\n if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;\n return ka.every((k) =>\n deepEquals((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),\n );\n }\n\n return false;\n}\n\n/** Absolute path to a suite's directory — for runners that read artifacts. */\nexport function suitePath(id: string): string {\n return resolve(join(packageRoot(), \"suites\", ...id.split(\"/\")));\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { dirname, join, relative, resolve, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport type {\n CaseResult,\n ConformanceCase,\n Language,\n RunSummary,\n Suite,\n SuiteManifest,\n} from \"./types\";\n\nexport * from \"./types\";\n\n/**\n * The repository root, whether this is running from `dist/` in an installed\n * package or from `src/` in a checkout.\n *\n * Resolved by walking up to the directory that holds `suites/`, rather than by\n * a fixed `../..`. The two existing parity harnesses in this suite both\n * hard-coded a relative path to a sibling checkout (`../../holy-sheet/src/`),\n * which is why they work in exactly one directory layout and silently no-op\n * everywhere else. This package must not repeat that.\n */\nfunction packageRoot(): string {\n let dir = dirname(fileURLToPath(import.meta.url));\n\n for (let i = 0; i < 6; i++) {\n try {\n if (statSync(join(dir, \"suites\")).isDirectory()) {\n return dir;\n }\n } catch {\n // keep walking\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n\n throw new Error(\n \"fancy-conformance: could not locate the suites/ directory. \" +\n \"If you vendored this package, keep suites/ next to dist/.\",\n );\n}\n\n/** The suite collection's own version — the thing a runner must print. */\nexport function suiteVersion(): string {\n return readFileSync(join(packageRoot(), \"VERSION\"), \"utf8\").trim();\n}\n\n/** Every suite id present, e.g. `[\"shared/decimal\", \"shared/satisfies-range\", …]`. */\nexport function listSuites(): string[] {\n const root = join(packageRoot(), \"suites\");\n const found: string[] = [];\n\n const walk = (dir: string): void => {\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue;\n const child = join(dir, entry.name);\n try {\n statSync(join(child, \"manifest.json\"));\n found.push(relative(root, child).split(sep).join(\"/\"));\n } catch {\n walk(child);\n }\n }\n };\n\n walk(root);\n return found.sort();\n}\n\n/** Load one suite's manifest and cases. Throws rather than returning a partial. */\nexport function loadSuite(id: string): Suite {\n return loadSuiteFrom(packageRoot(), id);\n}\n\n/**\n * Load a suite from an explicit root.\n *\n * Exported so the load-time guards below can be tested against a throwaway\n * fixture tree, rather than a test re-implementing them. A guard asserted by a\n * copy of itself is the failure mode this whole repository exists to stop, and\n * it would be an embarrassing one to ship here.\n */\nexport function loadSuiteFrom(root: string, id: string): Suite {\n const dir = join(root, \"suites\", ...id.split(\"/\"));\n const manifest = JSON.parse(readFileSync(join(dir, \"manifest.json\"), \"utf8\")) as SuiteManifest;\n\n if (manifest.caseFormat !== \"table\") {\n throw new Error(\n `fancy-conformance: suite \"${id}\" uses caseFormat \"${manifest.caseFormat}\", ` +\n \"which loadSuite() does not read. Use the artifact runner in runners/.\",\n );\n }\n\n const table = JSON.parse(\n readFileSync(join(dir, manifest.cases ?? \"cases.json\"), \"utf8\"),\n ) as { cases: ConformanceCase[] };\n\n assertUsableCases(id, table.cases);\n\n return { manifest, cases: table.cases };\n}\n\n/**\n * Reject a case table that cannot do its job, at LOAD time.\n *\n * A skip with no reason, and a duplicate id, are both silent in every other\n * respect: the suite still loads, still reports green, and still covers less\n * than it appears to. That is the exact failure this repository exists to stop,\n * so it is a hard error here rather than a lint somewhere else.\n */\nfunction assertUsableCases(id: string, cases: ConformanceCase[]): void {\n const seen = new Set<string>();\n\n for (const c of cases) {\n if (seen.has(c.id)) {\n throw new Error(`fancy-conformance: suite \"${id}\" has duplicate case id \"${c.id}\".`);\n }\n seen.add(c.id);\n\n for (const [lang, reason] of Object.entries(c.skip ?? {})) {\n if (typeof reason !== \"string\" || reason.trim() === \"\") {\n throw new Error(\n `fancy-conformance: case \"${id}/${c.id}\" skips ${lang} with no reason. ` +\n \"A skip must say why, because every runner prints it.\",\n );\n }\n }\n }\n}\n\nexport interface RunOptions {\n /** Which language is under test — decides which `skip` entries apply. */\n language: Language;\n /**\n * Compare a produced value with the expected one. Defaults to a\n * canonicalising deep equality: object keys sorted, arrays order-sensitive.\n */\n equals?: (actual: unknown, expected: unknown, tolerance?: number) => boolean;\n}\n\n/**\n * Run one implementation against a table suite.\n *\n * `impl` receives the case and returns the value to compare. Throwing is a\n * failure, not a crash — a case that blows up is data about the implementation.\n */\nexport function runTable(\n suiteId: string,\n impl: (c: ConformanceCase) => unknown,\n options: RunOptions,\n): RunSummary {\n const { manifest, cases } = loadSuite(suiteId);\n const equals = options.equals ?? deepEquals;\n const results: CaseResult[] = [];\n\n for (const c of cases) {\n const reason = c.skip?.[options.language];\n if (reason !== undefined) {\n results.push({ id: c.id, title: c.title, status: \"skip\", reason });\n continue;\n }\n\n let actual: unknown;\n try {\n actual = impl(c);\n } catch (error) {\n results.push({\n id: c.id,\n title: c.title,\n status: \"fail\",\n expected: c.expected,\n actual: `threw: ${error instanceof Error ? error.message : String(error)}`,\n });\n continue;\n }\n\n results.push(\n equals(actual, c.expected, toleranceFor(c))\n ? { id: c.id, title: c.title, status: \"pass\" }\n : { id: c.id, title: c.title, status: \"fail\", expected: c.expected, actual },\n );\n }\n\n const failed = results.filter((r) => r.status === \"fail\").length;\n\n return {\n suite: manifest.suite,\n language: options.language,\n suiteVersion: suiteVersion(),\n passed: results.filter((r) => r.status === \"pass\").length,\n failed,\n skipped: results.filter((r) => r.status === \"skip\").length,\n results,\n ok: failed === 0,\n };\n}\n\n/**\n * A summary a CI log can be read from — including every skip, by name and\n * reason.\n *\n * Skips are printed unconditionally and never folded into a count. \"3 skipped\"\n * in a log is indistinguishable from full coverage at a glance, which is how a\n * suite stops meaning anything without anyone deciding that it should.\n */\nexport function formatSummary(summary: RunSummary): string {\n const lines: string[] = [\n `${summary.suite} [${summary.language}] — fancy-conformance ${summary.suiteVersion}`,\n ` ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped`,\n ];\n\n for (const r of summary.results) {\n if (r.status === \"skip\") {\n lines.push(` SKIP ${r.id} — ${r.reason}`);\n }\n if (r.status === \"fail\") {\n lines.push(` FAIL ${r.id} ${r.title}`);\n lines.push(` expected: ${preview(r.expected)}`);\n lines.push(` actual: ${preview(r.actual)}`);\n }\n }\n\n return lines.join(\"\\n\");\n}\n\nfunction preview(value: unknown): string {\n const s = typeof value === \"string\" ? value : JSON.stringify(value);\n if (s === undefined) return String(value);\n return s.length > 120 ? `${s.slice(0, 60)}…${s.slice(-40)} (len ${s.length})` : s;\n}\n\n/** Order-sensitive for arrays, order-insensitive for object keys. */\n/**\n * A case's declared float tolerance, or `undefined` for exact comparison.\n *\n * Declared ON THE ROW so it is visible in the fixtures and in any diff of them.\n * A global epsilon is invisible: nobody reading a case can tell whether it is\n * asserting a value or a neighbourhood.\n *\n * A non-finite or boolean `tolerance` is ignored rather than trusted — a stray\n * `true` coerces to 1 in JavaScript, which would silently widen a row to accept\n * almost anything while still looking strict.\n */\nfunction toleranceFor(c: ConformanceCase): number | undefined {\n const tolerance = (c as { tolerance?: unknown }).tolerance;\n return typeof tolerance === \"number\" && Number.isFinite(tolerance) ? tolerance : undefined;\n}\n\nexport function deepEquals(a: unknown, b: unknown, tolerance?: number): boolean {\n if (Object.is(a, b)) return true;\n\n // Numbers compare EXACTLY unless the case declares a tolerance.\n //\n // This loader was already exact while PHP, Python and Rust used a scaled\n // 1e-12 epsilon -- a 3-1 split in the package whose product is agreement, and\n // recorded as such in AGENTS.md for months. The other three now match THIS\n // one, because the epsilon's stated justification turned out to be false:\n // every hard literal (0.002, 0.1, 1e300, DBL_MAX, the 5e-324 denormal)\n // parses to bit-identical doubles in all three languages.\n //\n // The tolerance is per-case and declared on the row, so it is visible in the\n // fixture rather than being a global behaviour a reader cannot see.\n if (typeof a === \"number\" && typeof b === \"number\") {\n if (tolerance === undefined) return false;\n const scale = Math.max(1, Math.abs(a), Math.abs(b));\n return Math.abs(a - b) <= tolerance * scale;\n }\n\n if (typeof a !== typeof b) return false;\n if (a === null || b === null) return false;\n\n if (Array.isArray(a) || Array.isArray(b)) {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;\n return a.every((v, i) => deepEquals(v, b[i], tolerance));\n }\n\n if (typeof a === \"object\") {\n const ka = Object.keys(a as object).sort();\n const kb = Object.keys(b as object).sort();\n if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;\n return ka.every((k) =>\n deepEquals((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], tolerance),\n );\n }\n\n return false;\n}\n\n/** Absolute path to a suite's directory — for runners that read artifacts. */\nexport function suitePath(id: string): string {\n return resolve(join(packageRoot(), \"suites\", ...id.split(\"/\")));\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -99,7 +99,7 @@ interface RunOptions {
|
|
|
99
99
|
* Compare a produced value with the expected one. Defaults to a
|
|
100
100
|
* canonicalising deep equality: object keys sorted, arrays order-sensitive.
|
|
101
101
|
*/
|
|
102
|
-
equals?: (actual: unknown, expected: unknown) => boolean;
|
|
102
|
+
equals?: (actual: unknown, expected: unknown, tolerance?: number) => boolean;
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
105
|
* Run one implementation against a table suite.
|
|
@@ -117,8 +117,7 @@ declare function runTable(suiteId: string, impl: (c: ConformanceCase) => unknown
|
|
|
117
117
|
* suite stops meaning anything without anyone deciding that it should.
|
|
118
118
|
*/
|
|
119
119
|
declare function formatSummary(summary: RunSummary): string;
|
|
120
|
-
|
|
121
|
-
declare function deepEquals(a: unknown, b: unknown): boolean;
|
|
120
|
+
declare function deepEquals(a: unknown, b: unknown, tolerance?: number): boolean;
|
|
122
121
|
/** Absolute path to a suite's directory — for runners that read artifacts. */
|
|
123
122
|
declare function suitePath(id: string): string;
|
|
124
123
|
|
package/dist/index.d.ts
CHANGED
|
@@ -99,7 +99,7 @@ interface RunOptions {
|
|
|
99
99
|
* Compare a produced value with the expected one. Defaults to a
|
|
100
100
|
* canonicalising deep equality: object keys sorted, arrays order-sensitive.
|
|
101
101
|
*/
|
|
102
|
-
equals?: (actual: unknown, expected: unknown) => boolean;
|
|
102
|
+
equals?: (actual: unknown, expected: unknown, tolerance?: number) => boolean;
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
105
|
* Run one implementation against a table suite.
|
|
@@ -117,8 +117,7 @@ declare function runTable(suiteId: string, impl: (c: ConformanceCase) => unknown
|
|
|
117
117
|
* suite stops meaning anything without anyone deciding that it should.
|
|
118
118
|
*/
|
|
119
119
|
declare function formatSummary(summary: RunSummary): string;
|
|
120
|
-
|
|
121
|
-
declare function deepEquals(a: unknown, b: unknown): boolean;
|
|
120
|
+
declare function deepEquals(a: unknown, b: unknown, tolerance?: number): boolean;
|
|
122
121
|
/** Absolute path to a suite's directory — for runners that read artifacts. */
|
|
123
122
|
declare function suitePath(id: string): string;
|
|
124
123
|
|
package/dist/index.js
CHANGED
|
@@ -97,7 +97,7 @@ function runTable(suiteId, impl, options) {
|
|
|
97
97
|
continue;
|
|
98
98
|
}
|
|
99
99
|
results.push(
|
|
100
|
-
equals(actual, c.expected) ? { id: c.id, title: c.title, status: "pass" } : { id: c.id, title: c.title, status: "fail", expected: c.expected, actual }
|
|
100
|
+
equals(actual, c.expected, toleranceFor(c)) ? { id: c.id, title: c.title, status: "pass" } : { id: c.id, title: c.title, status: "fail", expected: c.expected, actual }
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
const failed = results.filter((r) => r.status === "fail").length;
|
|
@@ -134,20 +134,29 @@ function preview(value) {
|
|
|
134
134
|
if (s === void 0) return String(value);
|
|
135
135
|
return s.length > 120 ? `${s.slice(0, 60)}\u2026${s.slice(-40)} (len ${s.length})` : s;
|
|
136
136
|
}
|
|
137
|
-
function
|
|
137
|
+
function toleranceFor(c) {
|
|
138
|
+
const tolerance = c.tolerance;
|
|
139
|
+
return typeof tolerance === "number" && Number.isFinite(tolerance) ? tolerance : void 0;
|
|
140
|
+
}
|
|
141
|
+
function deepEquals(a, b, tolerance) {
|
|
138
142
|
if (Object.is(a, b)) return true;
|
|
143
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
144
|
+
if (tolerance === void 0) return false;
|
|
145
|
+
const scale = Math.max(1, Math.abs(a), Math.abs(b));
|
|
146
|
+
return Math.abs(a - b) <= tolerance * scale;
|
|
147
|
+
}
|
|
139
148
|
if (typeof a !== typeof b) return false;
|
|
140
149
|
if (a === null || b === null) return false;
|
|
141
150
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
142
151
|
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
143
|
-
return a.every((v, i) => deepEquals(v, b[i]));
|
|
152
|
+
return a.every((v, i) => deepEquals(v, b[i], tolerance));
|
|
144
153
|
}
|
|
145
154
|
if (typeof a === "object") {
|
|
146
155
|
const ka = Object.keys(a).sort();
|
|
147
156
|
const kb = Object.keys(b).sort();
|
|
148
157
|
if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
|
|
149
158
|
return ka.every(
|
|
150
|
-
(k) => deepEquals(a[k], b[k])
|
|
159
|
+
(k) => deepEquals(a[k], b[k], tolerance)
|
|
151
160
|
);
|
|
152
161
|
}
|
|
153
162
|
return false;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { dirname, join, relative, resolve, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport type {\n CaseResult,\n ConformanceCase,\n Language,\n RunSummary,\n Suite,\n SuiteManifest,\n} from \"./types\";\n\nexport * from \"./types\";\n\n/**\n * The repository root, whether this is running from `dist/` in an installed\n * package or from `src/` in a checkout.\n *\n * Resolved by walking up to the directory that holds `suites/`, rather than by\n * a fixed `../..`. The two existing parity harnesses in this suite both\n * hard-coded a relative path to a sibling checkout (`../../holy-sheet/src/`),\n * which is why they work in exactly one directory layout and silently no-op\n * everywhere else. This package must not repeat that.\n */\nfunction packageRoot(): string {\n let dir = dirname(fileURLToPath(import.meta.url));\n\n for (let i = 0; i < 6; i++) {\n try {\n if (statSync(join(dir, \"suites\")).isDirectory()) {\n return dir;\n }\n } catch {\n // keep walking\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n\n throw new Error(\n \"fancy-conformance: could not locate the suites/ directory. \" +\n \"If you vendored this package, keep suites/ next to dist/.\",\n );\n}\n\n/** The suite collection's own version — the thing a runner must print. */\nexport function suiteVersion(): string {\n return readFileSync(join(packageRoot(), \"VERSION\"), \"utf8\").trim();\n}\n\n/** Every suite id present, e.g. `[\"shared/decimal\", \"shared/satisfies-range\", …]`. */\nexport function listSuites(): string[] {\n const root = join(packageRoot(), \"suites\");\n const found: string[] = [];\n\n const walk = (dir: string): void => {\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue;\n const child = join(dir, entry.name);\n try {\n statSync(join(child, \"manifest.json\"));\n found.push(relative(root, child).split(sep).join(\"/\"));\n } catch {\n walk(child);\n }\n }\n };\n\n walk(root);\n return found.sort();\n}\n\n/** Load one suite's manifest and cases. Throws rather than returning a partial. */\nexport function loadSuite(id: string): Suite {\n return loadSuiteFrom(packageRoot(), id);\n}\n\n/**\n * Load a suite from an explicit root.\n *\n * Exported so the load-time guards below can be tested against a throwaway\n * fixture tree, rather than a test re-implementing them. A guard asserted by a\n * copy of itself is the failure mode this whole repository exists to stop, and\n * it would be an embarrassing one to ship here.\n */\nexport function loadSuiteFrom(root: string, id: string): Suite {\n const dir = join(root, \"suites\", ...id.split(\"/\"));\n const manifest = JSON.parse(readFileSync(join(dir, \"manifest.json\"), \"utf8\")) as SuiteManifest;\n\n if (manifest.caseFormat !== \"table\") {\n throw new Error(\n `fancy-conformance: suite \"${id}\" uses caseFormat \"${manifest.caseFormat}\", ` +\n \"which loadSuite() does not read. Use the artifact runner in runners/.\",\n );\n }\n\n const table = JSON.parse(\n readFileSync(join(dir, manifest.cases ?? \"cases.json\"), \"utf8\"),\n ) as { cases: ConformanceCase[] };\n\n assertUsableCases(id, table.cases);\n\n return { manifest, cases: table.cases };\n}\n\n/**\n * Reject a case table that cannot do its job, at LOAD time.\n *\n * A skip with no reason, and a duplicate id, are both silent in every other\n * respect: the suite still loads, still reports green, and still covers less\n * than it appears to. That is the exact failure this repository exists to stop,\n * so it is a hard error here rather than a lint somewhere else.\n */\nfunction assertUsableCases(id: string, cases: ConformanceCase[]): void {\n const seen = new Set<string>();\n\n for (const c of cases) {\n if (seen.has(c.id)) {\n throw new Error(`fancy-conformance: suite \"${id}\" has duplicate case id \"${c.id}\".`);\n }\n seen.add(c.id);\n\n for (const [lang, reason] of Object.entries(c.skip ?? {})) {\n if (typeof reason !== \"string\" || reason.trim() === \"\") {\n throw new Error(\n `fancy-conformance: case \"${id}/${c.id}\" skips ${lang} with no reason. ` +\n \"A skip must say why, because every runner prints it.\",\n );\n }\n }\n }\n}\n\nexport interface RunOptions {\n /** Which language is under test — decides which `skip` entries apply. */\n language: Language;\n /**\n * Compare a produced value with the expected one. Defaults to a\n * canonicalising deep equality: object keys sorted, arrays order-sensitive.\n */\n equals?: (actual: unknown, expected: unknown) => boolean;\n}\n\n/**\n * Run one implementation against a table suite.\n *\n * `impl` receives the case and returns the value to compare. Throwing is a\n * failure, not a crash — a case that blows up is data about the implementation.\n */\nexport function runTable(\n suiteId: string,\n impl: (c: ConformanceCase) => unknown,\n options: RunOptions,\n): RunSummary {\n const { manifest, cases } = loadSuite(suiteId);\n const equals = options.equals ?? deepEquals;\n const results: CaseResult[] = [];\n\n for (const c of cases) {\n const reason = c.skip?.[options.language];\n if (reason !== undefined) {\n results.push({ id: c.id, title: c.title, status: \"skip\", reason });\n continue;\n }\n\n let actual: unknown;\n try {\n actual = impl(c);\n } catch (error) {\n results.push({\n id: c.id,\n title: c.title,\n status: \"fail\",\n expected: c.expected,\n actual: `threw: ${error instanceof Error ? error.message : String(error)}`,\n });\n continue;\n }\n\n results.push(\n equals(actual, c.expected)\n ? { id: c.id, title: c.title, status: \"pass\" }\n : { id: c.id, title: c.title, status: \"fail\", expected: c.expected, actual },\n );\n }\n\n const failed = results.filter((r) => r.status === \"fail\").length;\n\n return {\n suite: manifest.suite,\n language: options.language,\n suiteVersion: suiteVersion(),\n passed: results.filter((r) => r.status === \"pass\").length,\n failed,\n skipped: results.filter((r) => r.status === \"skip\").length,\n results,\n ok: failed === 0,\n };\n}\n\n/**\n * A summary a CI log can be read from — including every skip, by name and\n * reason.\n *\n * Skips are printed unconditionally and never folded into a count. \"3 skipped\"\n * in a log is indistinguishable from full coverage at a glance, which is how a\n * suite stops meaning anything without anyone deciding that it should.\n */\nexport function formatSummary(summary: RunSummary): string {\n const lines: string[] = [\n `${summary.suite} [${summary.language}] — fancy-conformance ${summary.suiteVersion}`,\n ` ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped`,\n ];\n\n for (const r of summary.results) {\n if (r.status === \"skip\") {\n lines.push(` SKIP ${r.id} — ${r.reason}`);\n }\n if (r.status === \"fail\") {\n lines.push(` FAIL ${r.id} ${r.title}`);\n lines.push(` expected: ${preview(r.expected)}`);\n lines.push(` actual: ${preview(r.actual)}`);\n }\n }\n\n return lines.join(\"\\n\");\n}\n\nfunction preview(value: unknown): string {\n const s = typeof value === \"string\" ? value : JSON.stringify(value);\n if (s === undefined) return String(value);\n return s.length > 120 ? `${s.slice(0, 60)}…${s.slice(-40)} (len ${s.length})` : s;\n}\n\n/** Order-sensitive for arrays, order-insensitive for object keys. */\nexport function deepEquals(a: unknown, b: unknown): boolean {\n if (Object.is(a, b)) return true;\n if (typeof a !== typeof b) return false;\n if (a === null || b === null) return false;\n\n if (Array.isArray(a) || Array.isArray(b)) {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;\n return a.every((v, i) => deepEquals(v, b[i]));\n }\n\n if (typeof a === \"object\") {\n const ka = Object.keys(a as object).sort();\n const kb = Object.keys(b as object).sort();\n if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;\n return ka.every((k) =>\n deepEquals((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),\n );\n }\n\n return false;\n}\n\n/** Absolute path to a suite's directory — for runners that read artifacts. */\nexport function suitePath(id: string): string {\n return resolve(join(packageRoot(), \"suites\", ...id.split(\"/\")));\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { dirname, join, relative, resolve, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport type {\n CaseResult,\n ConformanceCase,\n Language,\n RunSummary,\n Suite,\n SuiteManifest,\n} from \"./types\";\n\nexport * from \"./types\";\n\n/**\n * The repository root, whether this is running from `dist/` in an installed\n * package or from `src/` in a checkout.\n *\n * Resolved by walking up to the directory that holds `suites/`, rather than by\n * a fixed `../..`. The two existing parity harnesses in this suite both\n * hard-coded a relative path to a sibling checkout (`../../holy-sheet/src/`),\n * which is why they work in exactly one directory layout and silently no-op\n * everywhere else. This package must not repeat that.\n */\nfunction packageRoot(): string {\n let dir = dirname(fileURLToPath(import.meta.url));\n\n for (let i = 0; i < 6; i++) {\n try {\n if (statSync(join(dir, \"suites\")).isDirectory()) {\n return dir;\n }\n } catch {\n // keep walking\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n\n throw new Error(\n \"fancy-conformance: could not locate the suites/ directory. \" +\n \"If you vendored this package, keep suites/ next to dist/.\",\n );\n}\n\n/** The suite collection's own version — the thing a runner must print. */\nexport function suiteVersion(): string {\n return readFileSync(join(packageRoot(), \"VERSION\"), \"utf8\").trim();\n}\n\n/** Every suite id present, e.g. `[\"shared/decimal\", \"shared/satisfies-range\", …]`. */\nexport function listSuites(): string[] {\n const root = join(packageRoot(), \"suites\");\n const found: string[] = [];\n\n const walk = (dir: string): void => {\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue;\n const child = join(dir, entry.name);\n try {\n statSync(join(child, \"manifest.json\"));\n found.push(relative(root, child).split(sep).join(\"/\"));\n } catch {\n walk(child);\n }\n }\n };\n\n walk(root);\n return found.sort();\n}\n\n/** Load one suite's manifest and cases. Throws rather than returning a partial. */\nexport function loadSuite(id: string): Suite {\n return loadSuiteFrom(packageRoot(), id);\n}\n\n/**\n * Load a suite from an explicit root.\n *\n * Exported so the load-time guards below can be tested against a throwaway\n * fixture tree, rather than a test re-implementing them. A guard asserted by a\n * copy of itself is the failure mode this whole repository exists to stop, and\n * it would be an embarrassing one to ship here.\n */\nexport function loadSuiteFrom(root: string, id: string): Suite {\n const dir = join(root, \"suites\", ...id.split(\"/\"));\n const manifest = JSON.parse(readFileSync(join(dir, \"manifest.json\"), \"utf8\")) as SuiteManifest;\n\n if (manifest.caseFormat !== \"table\") {\n throw new Error(\n `fancy-conformance: suite \"${id}\" uses caseFormat \"${manifest.caseFormat}\", ` +\n \"which loadSuite() does not read. Use the artifact runner in runners/.\",\n );\n }\n\n const table = JSON.parse(\n readFileSync(join(dir, manifest.cases ?? \"cases.json\"), \"utf8\"),\n ) as { cases: ConformanceCase[] };\n\n assertUsableCases(id, table.cases);\n\n return { manifest, cases: table.cases };\n}\n\n/**\n * Reject a case table that cannot do its job, at LOAD time.\n *\n * A skip with no reason, and a duplicate id, are both silent in every other\n * respect: the suite still loads, still reports green, and still covers less\n * than it appears to. That is the exact failure this repository exists to stop,\n * so it is a hard error here rather than a lint somewhere else.\n */\nfunction assertUsableCases(id: string, cases: ConformanceCase[]): void {\n const seen = new Set<string>();\n\n for (const c of cases) {\n if (seen.has(c.id)) {\n throw new Error(`fancy-conformance: suite \"${id}\" has duplicate case id \"${c.id}\".`);\n }\n seen.add(c.id);\n\n for (const [lang, reason] of Object.entries(c.skip ?? {})) {\n if (typeof reason !== \"string\" || reason.trim() === \"\") {\n throw new Error(\n `fancy-conformance: case \"${id}/${c.id}\" skips ${lang} with no reason. ` +\n \"A skip must say why, because every runner prints it.\",\n );\n }\n }\n }\n}\n\nexport interface RunOptions {\n /** Which language is under test — decides which `skip` entries apply. */\n language: Language;\n /**\n * Compare a produced value with the expected one. Defaults to a\n * canonicalising deep equality: object keys sorted, arrays order-sensitive.\n */\n equals?: (actual: unknown, expected: unknown, tolerance?: number) => boolean;\n}\n\n/**\n * Run one implementation against a table suite.\n *\n * `impl` receives the case and returns the value to compare. Throwing is a\n * failure, not a crash — a case that blows up is data about the implementation.\n */\nexport function runTable(\n suiteId: string,\n impl: (c: ConformanceCase) => unknown,\n options: RunOptions,\n): RunSummary {\n const { manifest, cases } = loadSuite(suiteId);\n const equals = options.equals ?? deepEquals;\n const results: CaseResult[] = [];\n\n for (const c of cases) {\n const reason = c.skip?.[options.language];\n if (reason !== undefined) {\n results.push({ id: c.id, title: c.title, status: \"skip\", reason });\n continue;\n }\n\n let actual: unknown;\n try {\n actual = impl(c);\n } catch (error) {\n results.push({\n id: c.id,\n title: c.title,\n status: \"fail\",\n expected: c.expected,\n actual: `threw: ${error instanceof Error ? error.message : String(error)}`,\n });\n continue;\n }\n\n results.push(\n equals(actual, c.expected, toleranceFor(c))\n ? { id: c.id, title: c.title, status: \"pass\" }\n : { id: c.id, title: c.title, status: \"fail\", expected: c.expected, actual },\n );\n }\n\n const failed = results.filter((r) => r.status === \"fail\").length;\n\n return {\n suite: manifest.suite,\n language: options.language,\n suiteVersion: suiteVersion(),\n passed: results.filter((r) => r.status === \"pass\").length,\n failed,\n skipped: results.filter((r) => r.status === \"skip\").length,\n results,\n ok: failed === 0,\n };\n}\n\n/**\n * A summary a CI log can be read from — including every skip, by name and\n * reason.\n *\n * Skips are printed unconditionally and never folded into a count. \"3 skipped\"\n * in a log is indistinguishable from full coverage at a glance, which is how a\n * suite stops meaning anything without anyone deciding that it should.\n */\nexport function formatSummary(summary: RunSummary): string {\n const lines: string[] = [\n `${summary.suite} [${summary.language}] — fancy-conformance ${summary.suiteVersion}`,\n ` ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped`,\n ];\n\n for (const r of summary.results) {\n if (r.status === \"skip\") {\n lines.push(` SKIP ${r.id} — ${r.reason}`);\n }\n if (r.status === \"fail\") {\n lines.push(` FAIL ${r.id} ${r.title}`);\n lines.push(` expected: ${preview(r.expected)}`);\n lines.push(` actual: ${preview(r.actual)}`);\n }\n }\n\n return lines.join(\"\\n\");\n}\n\nfunction preview(value: unknown): string {\n const s = typeof value === \"string\" ? value : JSON.stringify(value);\n if (s === undefined) return String(value);\n return s.length > 120 ? `${s.slice(0, 60)}…${s.slice(-40)} (len ${s.length})` : s;\n}\n\n/** Order-sensitive for arrays, order-insensitive for object keys. */\n/**\n * A case's declared float tolerance, or `undefined` for exact comparison.\n *\n * Declared ON THE ROW so it is visible in the fixtures and in any diff of them.\n * A global epsilon is invisible: nobody reading a case can tell whether it is\n * asserting a value or a neighbourhood.\n *\n * A non-finite or boolean `tolerance` is ignored rather than trusted — a stray\n * `true` coerces to 1 in JavaScript, which would silently widen a row to accept\n * almost anything while still looking strict.\n */\nfunction toleranceFor(c: ConformanceCase): number | undefined {\n const tolerance = (c as { tolerance?: unknown }).tolerance;\n return typeof tolerance === \"number\" && Number.isFinite(tolerance) ? tolerance : undefined;\n}\n\nexport function deepEquals(a: unknown, b: unknown, tolerance?: number): boolean {\n if (Object.is(a, b)) return true;\n\n // Numbers compare EXACTLY unless the case declares a tolerance.\n //\n // This loader was already exact while PHP, Python and Rust used a scaled\n // 1e-12 epsilon -- a 3-1 split in the package whose product is agreement, and\n // recorded as such in AGENTS.md for months. The other three now match THIS\n // one, because the epsilon's stated justification turned out to be false:\n // every hard literal (0.002, 0.1, 1e300, DBL_MAX, the 5e-324 denormal)\n // parses to bit-identical doubles in all three languages.\n //\n // The tolerance is per-case and declared on the row, so it is visible in the\n // fixture rather than being a global behaviour a reader cannot see.\n if (typeof a === \"number\" && typeof b === \"number\") {\n if (tolerance === undefined) return false;\n const scale = Math.max(1, Math.abs(a), Math.abs(b));\n return Math.abs(a - b) <= tolerance * scale;\n }\n\n if (typeof a !== typeof b) return false;\n if (a === null || b === null) return false;\n\n if (Array.isArray(a) || Array.isArray(b)) {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;\n return a.every((v, i) => deepEquals(v, b[i], tolerance));\n }\n\n if (typeof a === \"object\") {\n const ka = Object.keys(a as object).sort();\n const kb = Object.keys(b as object).sort();\n if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;\n return ka.every((k) =>\n deepEquals((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], tolerance),\n );\n }\n\n return false;\n}\n\n/** Absolute path to a suite's directory — for runners that read artifacts. */\nexport function suitePath(id: string): string {\n return resolve(join(packageRoot(), \"suites\", ...id.split(\"/\")));\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@particle-academy/fancy-conformance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Shared cross-language conformance fixtures for the Fancy suite. One contract, N implementations, and a single table that every implementation asserts in its own CI \u2014 so 'parity' is a test result rather than a claim. Ships the fixture data itself, so a Rust, Go or Python runner can consume it without a JavaScript toolchain.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,6 +47,11 @@
|
|
|
47
47
|
"additionalProperties": { "type": "string", "minLength": 1 },
|
|
48
48
|
"minProperties": 1
|
|
49
49
|
},
|
|
50
|
+
"tolerance": {
|
|
51
|
+
"description": "Optional relative tolerance for FLOAT comparison in this case, scaled by the larger magnitude. Omit it and numbers compare EXACTLY, which is the default in all four loaders. Declared on the row rather than applied globally so a reader of the fixture can see whether it asserts a value or a neighbourhood — the same reason a skip must state its reason. A global 1e-12 epsilon lived in three loaders until 0.10.0 and let two runtimes that computed different values pass as equal.",
|
|
52
|
+
"type": "number",
|
|
53
|
+
"exclusiveMinimum": 0
|
|
54
|
+
},
|
|
50
55
|
"notes": { "type": "string", "minLength": 1 }
|
|
51
56
|
},
|
|
52
57
|
"additionalProperties": false
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/cases.schema.json",
|
|
3
|
+
"suite": "shared/value-equality",
|
|
4
|
+
"cases": [
|
|
5
|
+
{
|
|
6
|
+
"id": "0001-identical-strings",
|
|
7
|
+
"title": "Identical strings are equal.",
|
|
8
|
+
"since": "0.11.0",
|
|
9
|
+
"tags": [
|
|
10
|
+
"scalars"
|
|
11
|
+
],
|
|
12
|
+
"input": {
|
|
13
|
+
"a": "a",
|
|
14
|
+
"b": "a"
|
|
15
|
+
},
|
|
16
|
+
"expected": true
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "0002-different-strings",
|
|
20
|
+
"title": "Different strings are not.",
|
|
21
|
+
"since": "0.11.0",
|
|
22
|
+
"tags": [
|
|
23
|
+
"scalars"
|
|
24
|
+
],
|
|
25
|
+
"input": {
|
|
26
|
+
"a": "a",
|
|
27
|
+
"b": "b"
|
|
28
|
+
},
|
|
29
|
+
"expected": false
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "0003-identical-integers",
|
|
33
|
+
"title": "Identical integers are equal.",
|
|
34
|
+
"since": "0.11.0",
|
|
35
|
+
"tags": [
|
|
36
|
+
"scalars"
|
|
37
|
+
],
|
|
38
|
+
"input": {
|
|
39
|
+
"a": 3,
|
|
40
|
+
"b": 3
|
|
41
|
+
},
|
|
42
|
+
"expected": true
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "0004-different-integers",
|
|
46
|
+
"title": "Different integers are not.",
|
|
47
|
+
"since": "0.11.0",
|
|
48
|
+
"tags": [
|
|
49
|
+
"scalars"
|
|
50
|
+
],
|
|
51
|
+
"input": {
|
|
52
|
+
"a": 2,
|
|
53
|
+
"b": 3
|
|
54
|
+
},
|
|
55
|
+
"expected": false
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "0005-null-equals-null",
|
|
59
|
+
"title": "null equals null.",
|
|
60
|
+
"since": "0.11.0",
|
|
61
|
+
"tags": [
|
|
62
|
+
"scalars",
|
|
63
|
+
"absence"
|
|
64
|
+
],
|
|
65
|
+
"input": {
|
|
66
|
+
"a": null,
|
|
67
|
+
"b": null
|
|
68
|
+
},
|
|
69
|
+
"expected": true
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "0101-int-golden-satisfied-by-float",
|
|
73
|
+
"title": "An integer is equal to a float of the same value.",
|
|
74
|
+
"since": "0.11.0",
|
|
75
|
+
"tags": [
|
|
76
|
+
"numbers",
|
|
77
|
+
"regression",
|
|
78
|
+
"trap"
|
|
79
|
+
],
|
|
80
|
+
"input": {
|
|
81
|
+
"a": 100000,
|
|
82
|
+
"b": 100000.0
|
|
83
|
+
},
|
|
84
|
+
"expected": true,
|
|
85
|
+
"notes": "The rule a Rust unit test denied while `shared/decimal/0008-coerce-exponent` required it: that case has the integer golden 100000 and PHP's `\"1e5\" + 0` yields float(100000). The reference language has ONE number type, so no golden can demand a float."
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"id": "0102-float-golden-satisfied-by-int",
|
|
89
|
+
"title": "...and the same in reverse.",
|
|
90
|
+
"since": "0.11.0",
|
|
91
|
+
"tags": [
|
|
92
|
+
"numbers",
|
|
93
|
+
"trap"
|
|
94
|
+
],
|
|
95
|
+
"input": {
|
|
96
|
+
"a": 3.0,
|
|
97
|
+
"b": 3
|
|
98
|
+
},
|
|
99
|
+
"expected": true
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"id": "0103-different-values-across-types",
|
|
103
|
+
"title": "Different values are unequal regardless of type.",
|
|
104
|
+
"since": "0.11.0",
|
|
105
|
+
"tags": [
|
|
106
|
+
"numbers"
|
|
107
|
+
],
|
|
108
|
+
"input": {
|
|
109
|
+
"a": 2,
|
|
110
|
+
"b": 3.0
|
|
111
|
+
},
|
|
112
|
+
"expected": false
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"id": "0110-true-is-not-one",
|
|
116
|
+
"title": "true is not 1.",
|
|
117
|
+
"since": "0.11.0",
|
|
118
|
+
"tags": [
|
|
119
|
+
"booleans",
|
|
120
|
+
"trap"
|
|
121
|
+
],
|
|
122
|
+
"input": {
|
|
123
|
+
"a": true,
|
|
124
|
+
"b": 1
|
|
125
|
+
},
|
|
126
|
+
"expected": false,
|
|
127
|
+
"notes": "Python's `==` says `True == 1`, and PHP's loose comparison agrees. Without this a row expecting `false` is satisfied by an implementation returning `0`."
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"id": "0111-false-is-not-zero",
|
|
131
|
+
"title": "false is not 0.",
|
|
132
|
+
"since": "0.11.0",
|
|
133
|
+
"tags": [
|
|
134
|
+
"booleans",
|
|
135
|
+
"trap"
|
|
136
|
+
],
|
|
137
|
+
"input": {
|
|
138
|
+
"a": false,
|
|
139
|
+
"b": 0
|
|
140
|
+
},
|
|
141
|
+
"expected": false
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"id": "0112-true-is-not-the-string",
|
|
145
|
+
"title": "true is not \"true\".",
|
|
146
|
+
"since": "0.11.0",
|
|
147
|
+
"tags": [
|
|
148
|
+
"booleans"
|
|
149
|
+
],
|
|
150
|
+
"input": {
|
|
151
|
+
"a": true,
|
|
152
|
+
"b": "true"
|
|
153
|
+
},
|
|
154
|
+
"expected": false
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"id": "0120-same-double",
|
|
158
|
+
"title": "The same double is equal to itself.",
|
|
159
|
+
"since": "0.11.0",
|
|
160
|
+
"tags": [
|
|
161
|
+
"floats"
|
|
162
|
+
],
|
|
163
|
+
"input": {
|
|
164
|
+
"a": 0.1,
|
|
165
|
+
"b": 0.1
|
|
166
|
+
},
|
|
167
|
+
"expected": true
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"id": "0121-adjacent-doubles-are-not-equal",
|
|
171
|
+
"title": "Genuinely different doubles are NOT equal.",
|
|
172
|
+
"since": "0.11.0",
|
|
173
|
+
"tags": [
|
|
174
|
+
"floats",
|
|
175
|
+
"regression"
|
|
176
|
+
],
|
|
177
|
+
"input": {
|
|
178
|
+
"a": 1.0,
|
|
179
|
+
"b": 1.0000000000001
|
|
180
|
+
},
|
|
181
|
+
"expected": false,
|
|
182
|
+
"notes": "A scaled 1e-12 epsilon in three of the four loaders passed this until 0.10.0. On a money row a relative 1e-12 is real money at scale."
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"id": "0122-tolerance-accepts-a-near-miss",
|
|
186
|
+
"title": "A declared tolerance accepts a near miss.",
|
|
187
|
+
"since": "0.11.0",
|
|
188
|
+
"tags": [
|
|
189
|
+
"floats",
|
|
190
|
+
"tolerance"
|
|
191
|
+
],
|
|
192
|
+
"input": {
|
|
193
|
+
"a": 1.0,
|
|
194
|
+
"b": 1.0000000000001,
|
|
195
|
+
"tolerance": 1e-09
|
|
196
|
+
},
|
|
197
|
+
"expected": true
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"id": "0123-tolerance-is-not-unlimited",
|
|
201
|
+
"title": "A tolerance widens the target; it does not remove it.",
|
|
202
|
+
"since": "0.11.0",
|
|
203
|
+
"tags": [
|
|
204
|
+
"floats",
|
|
205
|
+
"tolerance"
|
|
206
|
+
],
|
|
207
|
+
"input": {
|
|
208
|
+
"a": 1.0,
|
|
209
|
+
"b": 1.5,
|
|
210
|
+
"tolerance": 1e-09
|
|
211
|
+
},
|
|
212
|
+
"expected": false
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"id": "0124-tolerance-tighter-than-the-gap",
|
|
216
|
+
"title": "A tolerance smaller than the gap still rejects.",
|
|
217
|
+
"since": "0.11.0",
|
|
218
|
+
"tags": [
|
|
219
|
+
"floats",
|
|
220
|
+
"tolerance"
|
|
221
|
+
],
|
|
222
|
+
"input": {
|
|
223
|
+
"a": 1.0,
|
|
224
|
+
"b": 1.0000000000001,
|
|
225
|
+
"tolerance": 1e-15
|
|
226
|
+
},
|
|
227
|
+
"expected": false
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"id": "0130-numeric-string-is-not-a-number",
|
|
231
|
+
"title": "\"1\" is not 1.",
|
|
232
|
+
"since": "0.11.0",
|
|
233
|
+
"tags": [
|
|
234
|
+
"scalars",
|
|
235
|
+
"trap"
|
|
236
|
+
],
|
|
237
|
+
"input": {
|
|
238
|
+
"a": "1",
|
|
239
|
+
"b": 1
|
|
240
|
+
},
|
|
241
|
+
"expected": false,
|
|
242
|
+
"notes": "PHP's `==` coerces a numeric string to a number. A golden of 1 must not be satisfied by an implementation returning the string."
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"id": "0140-arrays-order-matters",
|
|
246
|
+
"title": "Array order is significant.",
|
|
247
|
+
"since": "0.11.0",
|
|
248
|
+
"tags": [
|
|
249
|
+
"arrays"
|
|
250
|
+
],
|
|
251
|
+
"input": {
|
|
252
|
+
"a": [
|
|
253
|
+
1,
|
|
254
|
+
2
|
|
255
|
+
],
|
|
256
|
+
"b": [
|
|
257
|
+
2,
|
|
258
|
+
1
|
|
259
|
+
]
|
|
260
|
+
},
|
|
261
|
+
"expected": false
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"id": "0141-arrays-same-order",
|
|
265
|
+
"title": "Same values in the same order are equal.",
|
|
266
|
+
"since": "0.11.0",
|
|
267
|
+
"tags": [
|
|
268
|
+
"arrays"
|
|
269
|
+
],
|
|
270
|
+
"input": {
|
|
271
|
+
"a": [
|
|
272
|
+
1,
|
|
273
|
+
2
|
|
274
|
+
],
|
|
275
|
+
"b": [
|
|
276
|
+
1,
|
|
277
|
+
2
|
|
278
|
+
]
|
|
279
|
+
},
|
|
280
|
+
"expected": true
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"id": "0142-arrays-different-length",
|
|
284
|
+
"title": "Different lengths are unequal.",
|
|
285
|
+
"since": "0.11.0",
|
|
286
|
+
"tags": [
|
|
287
|
+
"arrays"
|
|
288
|
+
],
|
|
289
|
+
"input": {
|
|
290
|
+
"a": [
|
|
291
|
+
1
|
|
292
|
+
],
|
|
293
|
+
"b": [
|
|
294
|
+
1,
|
|
295
|
+
2
|
|
296
|
+
]
|
|
297
|
+
},
|
|
298
|
+
"expected": false
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"id": "0143-empty-arrays",
|
|
302
|
+
"title": "Two empty arrays are equal.",
|
|
303
|
+
"since": "0.11.0",
|
|
304
|
+
"tags": [
|
|
305
|
+
"arrays"
|
|
306
|
+
],
|
|
307
|
+
"input": {
|
|
308
|
+
"a": [],
|
|
309
|
+
"b": []
|
|
310
|
+
},
|
|
311
|
+
"expected": true
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"id": "0150-object-key-order-does-not-matter",
|
|
315
|
+
"title": "Object key order is NOT significant.",
|
|
316
|
+
"since": "0.11.0",
|
|
317
|
+
"tags": [
|
|
318
|
+
"objects"
|
|
319
|
+
],
|
|
320
|
+
"input": {
|
|
321
|
+
"a": {
|
|
322
|
+
"a": 1,
|
|
323
|
+
"b": 2
|
|
324
|
+
},
|
|
325
|
+
"b": {
|
|
326
|
+
"b": 2,
|
|
327
|
+
"a": 1
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
"expected": true
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"id": "0151-object-different-values",
|
|
334
|
+
"title": "Same keys, different values, unequal.",
|
|
335
|
+
"since": "0.11.0",
|
|
336
|
+
"tags": [
|
|
337
|
+
"objects"
|
|
338
|
+
],
|
|
339
|
+
"input": {
|
|
340
|
+
"a": {
|
|
341
|
+
"a": 1
|
|
342
|
+
},
|
|
343
|
+
"b": {
|
|
344
|
+
"a": 2
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
"expected": false
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
"id": "0152-nested",
|
|
351
|
+
"title": "Nesting is compared all the way down.",
|
|
352
|
+
"since": "0.11.0",
|
|
353
|
+
"tags": [
|
|
354
|
+
"objects"
|
|
355
|
+
],
|
|
356
|
+
"input": {
|
|
357
|
+
"a": {
|
|
358
|
+
"a": {
|
|
359
|
+
"b": [
|
|
360
|
+
1,
|
|
361
|
+
{
|
|
362
|
+
"c": "d"
|
|
363
|
+
}
|
|
364
|
+
]
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
"b": {
|
|
368
|
+
"a": {
|
|
369
|
+
"b": [
|
|
370
|
+
1,
|
|
371
|
+
{
|
|
372
|
+
"c": "d"
|
|
373
|
+
}
|
|
374
|
+
]
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
"expected": true
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
"id": "0153-nested-differs-deep",
|
|
382
|
+
"title": "A difference at depth is still a difference.",
|
|
383
|
+
"since": "0.11.0",
|
|
384
|
+
"tags": [
|
|
385
|
+
"objects"
|
|
386
|
+
],
|
|
387
|
+
"input": {
|
|
388
|
+
"a": {
|
|
389
|
+
"a": {
|
|
390
|
+
"b": [
|
|
391
|
+
1,
|
|
392
|
+
{
|
|
393
|
+
"c": "d"
|
|
394
|
+
}
|
|
395
|
+
]
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
"b": {
|
|
399
|
+
"a": {
|
|
400
|
+
"b": [
|
|
401
|
+
1,
|
|
402
|
+
{
|
|
403
|
+
"c": "e"
|
|
404
|
+
}
|
|
405
|
+
]
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
"expected": false
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
"id": "0201-explicit-null-key-vs-missing-key",
|
|
413
|
+
"title": "An object with an explicit null value is NOT equal to one missing that key.",
|
|
414
|
+
"since": "0.11.0",
|
|
415
|
+
"tags": [
|
|
416
|
+
"absence",
|
|
417
|
+
"trap"
|
|
418
|
+
],
|
|
419
|
+
"input": {
|
|
420
|
+
"a": {
|
|
421
|
+
"x": null
|
|
422
|
+
},
|
|
423
|
+
"b": {}
|
|
424
|
+
},
|
|
425
|
+
"expected": false,
|
|
426
|
+
"notes": "The axis a PHP-referenced corpus cannot express: PHP has ONE absent value while JavaScript has null and undefined and Python has None. Raised by an agent building a parity repo whose reference IS PHP, who spotted that a TypeScript loader could assert a null/undefined distinction no PHP-authored golden can encode, pass its own tests, and be wrong. Pinned here so the four loaders' current agreement is a test result rather than a coincidence."
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"id": "0202-both-missing",
|
|
430
|
+
"title": "Two objects both missing the key are equal.",
|
|
431
|
+
"since": "0.11.0",
|
|
432
|
+
"tags": [
|
|
433
|
+
"absence"
|
|
434
|
+
],
|
|
435
|
+
"input": {
|
|
436
|
+
"a": {},
|
|
437
|
+
"b": {}
|
|
438
|
+
},
|
|
439
|
+
"expected": true
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
"id": "0203-null-is-not-zero",
|
|
443
|
+
"title": "null is not 0.",
|
|
444
|
+
"since": "0.11.0",
|
|
445
|
+
"tags": [
|
|
446
|
+
"absence",
|
|
447
|
+
"trap"
|
|
448
|
+
],
|
|
449
|
+
"input": {
|
|
450
|
+
"a": null,
|
|
451
|
+
"b": 0
|
|
452
|
+
},
|
|
453
|
+
"expected": false
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"id": "0204-null-is-not-empty-string",
|
|
457
|
+
"title": "null is not \"\".",
|
|
458
|
+
"since": "0.11.0",
|
|
459
|
+
"tags": [
|
|
460
|
+
"absence",
|
|
461
|
+
"trap"
|
|
462
|
+
],
|
|
463
|
+
"input": {
|
|
464
|
+
"a": null,
|
|
465
|
+
"b": ""
|
|
466
|
+
},
|
|
467
|
+
"expected": false
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
"id": "0205-null-inside-an-array-holds-its-place",
|
|
471
|
+
"title": "A null inside an array is a value, not a gap.",
|
|
472
|
+
"since": "0.11.0",
|
|
473
|
+
"tags": [
|
|
474
|
+
"absence",
|
|
475
|
+
"arrays"
|
|
476
|
+
],
|
|
477
|
+
"input": {
|
|
478
|
+
"a": [
|
|
479
|
+
1,
|
|
480
|
+
null,
|
|
481
|
+
3
|
|
482
|
+
],
|
|
483
|
+
"b": [
|
|
484
|
+
1,
|
|
485
|
+
3
|
|
486
|
+
]
|
|
487
|
+
},
|
|
488
|
+
"expected": false
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
"id": "0210-array-is-not-an-object",
|
|
492
|
+
"title": "An array is not an object.",
|
|
493
|
+
"since": "0.11.0",
|
|
494
|
+
"tags": [
|
|
495
|
+
"containers",
|
|
496
|
+
"trap"
|
|
497
|
+
],
|
|
498
|
+
"input": {
|
|
499
|
+
"a": [],
|
|
500
|
+
"b": {}
|
|
501
|
+
},
|
|
502
|
+
"expected": false,
|
|
503
|
+
"notes": "PHP has ONE array type for both, so this needs asserting rather than assuming. FOUND BY THIS SUITE ON ITS FIRST RUN, which is the argument for the suite: three loaders agreed and the fourth could not even express the question, and nothing had ever asked them the same question side by side.",
|
|
504
|
+
"skip": {
|
|
505
|
+
"php": "Not representable in PHP. `json_decode('[]', true)` and `json_decode('{}', true)` produce the IDENTICAL value -- an empty PHP array -- so the loader has nothing left to compare. This is a real expressiveness limit rather than a loader defect: a PHP-authored golden cannot distinguish an empty array from an empty object at all. Node, Python and Rust all keep the distinction and assert it."
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
"id": "0211-array-is-not-a-scalar",
|
|
510
|
+
"title": "An array is not a scalar.",
|
|
511
|
+
"since": "0.11.0",
|
|
512
|
+
"tags": [
|
|
513
|
+
"containers"
|
|
514
|
+
],
|
|
515
|
+
"input": {
|
|
516
|
+
"a": [
|
|
517
|
+
1
|
|
518
|
+
],
|
|
519
|
+
"b": 1
|
|
520
|
+
},
|
|
521
|
+
"expected": false
|
|
522
|
+
}
|
|
523
|
+
]
|
|
524
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/suite-manifest.schema.json",
|
|
3
|
+
"suite": "shared/value-equality",
|
|
4
|
+
"title": "What the LOADERS mean by equal",
|
|
5
|
+
"since": "0.11.0",
|
|
6
|
+
"caseFormat": "table",
|
|
7
|
+
"cases": "cases.json",
|
|
8
|
+
"contract": {
|
|
9
|
+
"function": "equals(a: json, b: json, tolerance: number|null) -> boolean",
|
|
10
|
+
"summary": "Each loader's own comparison, run against a shared table. `a` and `b` are arbitrary JSON values; `tolerance` is the case's declared relative float tolerance, or null for exact. The result is whether that loader considers the two values equal.",
|
|
11
|
+
"reference": "node",
|
|
12
|
+
"referenceNote": "The rules were settled in 0.10.0 when the four loaders' float comparison was unified. This table makes them a test result rather than four independent readings of the same paragraph.",
|
|
13
|
+
"implementations": [
|
|
14
|
+
{ "language": "node", "package": "@particle-academy/fancy-conformance", "symbol": "deepEquals" },
|
|
15
|
+
{ "language": "php", "package": "particle-academy/fancy-conformance", "symbol": "ParticleAcademy\\Conformance\\Conformance::equals" },
|
|
16
|
+
{ "language": "python", "package": "fancy-conformance", "symbol": "fancy_conformance.equals" },
|
|
17
|
+
{ "language": "rust", "package": "fancy-conformance", "symbol": "fancy_conformance::equals_within" }
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"notes": [
|
|
21
|
+
"THIS PACKAGE HELD EVERY IMPLEMENTATION TO A TABLE EXCEPT ITSELF. The loaders are implementations too, and until this suite existed each one's comparison was asserted only by its own hand-written unit tests, against pairs its own author chose. That is precisely the setup this repository exists to argue against.",
|
|
22
|
+
"It was not hypothetical. The Rust loader asserted that an integer golden is never satisfied by a float, and had a passing unit test saying so — while `shared/decimal/0008-coerce-exponent` carries the integer golden `100000` that PHP's `\"1e5\" + 0` satisfies with a float. A loader had encoded a rule the shipped corpus disproves, and nothing ran the two against each other. It surfaced in 0.10.0 only because an unrelated change happened to break it.",
|
|
23
|
+
"The generalisation, and the reason this suite is worth its weight: A LOADER CAN ASSERT SOMETHING THE REFERENCE LANGUAGE CANNOT EXPRESS, AND NO NUMBER OF GREEN TICKS WILL SURFACE IT. The reference here is JavaScript, which has ONE number type, so no golden can encode \"this must be a float\" — a loader enforcing that distinction asserts something the fixtures are structurally unable to state.",
|
|
24
|
+
"Raised independently by an agent building a parity repo for another package family, who identified their own instance before writing it: their reference is PHP, which has ONE absent value, while TypeScript has `null` and `undefined` and Python has `None`. A TS loader could very reasonably assert a null-versus-undefined distinction that a PHP-authored golden cannot encode, pass its own tests, and be wrong in exactly the Rust way. Cases 0201-0204 pin the absent-versus-null axis for that reason.",
|
|
25
|
+
"Numbers compare BY VALUE, not by JSON type (0101-0103). Booleans are never numbers (0110-0111) — Python's `==` says `True == 1` and PHP's loose comparison agrees, so this needs asserting rather than assuming.",
|
|
26
|
+
"Floats are EXACT unless the case declares a tolerance (0120-0124). A scaled 1e-12 epsilon lived in three of the four loaders until 0.10.0, on a justification that measured false: `0.002`, `0.1`, `1e300`, `DBL_MAX`, the `5e-324` denormal and `0.30000000000000004` all parse to bit-identical doubles in PHP, Python and Node."
|
|
27
|
+
]
|
|
28
|
+
}
|