diff --git a/package-lock.json b/package-lock.json index 7f7906e..36d148c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "@open-formulieren/InferNoLogic", - "version": "0.1.0", + "name": "@open-formulieren/infernologic", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@open-formulieren/InferNoLogic", - "version": "0.1.0", + "name": "@open-formulieren/infernologic", + "version": "0.1.1", "license": "MIT", "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.5.7", "@types/node": "^18", + "fast-check": "^3.14.0", "jest": "^29.7.0", "prettier": "^2.8.8", "ts-jest": "^29.1.1", @@ -2680,6 +2681,28 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fast-check": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.14.0.tgz", + "integrity": "sha512-9Z0zqASzDNjXBox/ileV/fd+4P+V/f3o4shM6QawvcdLFh8yjPG4h5BrHUZ8yzY6amKGDTAmRMyb/JZqe+dCgw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", diff --git a/package.json b/package.json index 7ff9f7b..0fb3cb7 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.5.7", "@types/node": "^18", + "fast-check": "^3.14.0", "jest": "^29.7.0", "prettier": "^2.8.8", "ts-jest": "^29.1.1", diff --git a/src/helper.ts b/src/helper.ts index 0aad7a1..289ff3c 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -1,7 +1,17 @@ // Copyright (c) 2023 Adam Jones // // SPDX-License-Identifier: MIT -import {Context, MonoType, PolyType, TypeVariable, isContext, makeContext} from './models'; +import { + Context, + ExplainPath, + Expression, + MonoType, + PolyType, + TypeFunctionApplication, + TypeVariable, + isContext, + makeContext, +} from './models'; // substitutions @@ -45,8 +55,9 @@ function apply( if (value.type === 'ty-quantifier') { return {...value, sigma: apply(s, value.sigma)}; } - - throw new Error('Unknown argument passed to substitution'); + ((_: never): never => { + throw new Error('Unknown argument passed to substitution'); + })(value); } const combine = (s1: Substitution, s2: Substitution): Substitution => { @@ -84,7 +95,9 @@ export const instantiate = ( return instantiate(type.sigma, mappings); } - throw new Error('Unknown type passed to instantiate'); + ((_: never): never => { + throw new Error('Unknown type passed to instantiate'); + })(type); }; // generalise @@ -119,30 +132,53 @@ const freeVars = (value: PolyType | Context): string[] => { return freeVars(value.sigma).filter(v => v !== value.a); } - throw new Error('Unknown argument passed to substitution'); + ((_: never): never => { + throw new Error('Unknown argument passed to substitution'); + })(value); }; // unify -export const unify = (type1: MonoType, type2: MonoType): Substitution => { +export const unify = ( + type1: MonoType, + type2: MonoType, + expr: Expression, + path1: ExplainPath = [], + path2: ExplainPath = [] +): Substitution => { if (type1.type === 'ty-var' && type2.type === 'ty-var' && type1.a === type2.a) { return makeSubstitution({}); } if (type1.type === 'ty-var') { - if (contains(type2, type1)) throw new Error('Infinite type detected'); - + if (contains(type2, type1)) + throw new Error(`Infinite type detected: ${type1} occurs in ${type2}`); + + if (type2.type === 'ty-var') { + // var with other name -> explain + // TODO? reverseAliasPath(type1); + type1.explain = [type2, {type: 'ExplainAlias', path1, path2, expr}]; + } else if (type2.type === 'ty-app') { + // instantiation + // TODO? reverseAliasPath(type1); + type1.explain = [type2, {type: 'ExplainInstan', path: path1, expr}]; + } else { + ((_: never): never => { + throw new Error('Unknown argument passed to unify'); + })(type2); + } return makeSubstitution({ [type1.a]: type2, }); } if (type2.type === 'ty-var') { - return unify(type2, type1); + return unify(type2, type1, expr, path2, path1); } if (type1.C !== type2.C) { - throw new Error(`Could not unify types (different type functions): ${type1.C} and ${type2.C}`); + const msg = formatUnificationError(type1, type2, expr, path1, path2); + throw new Error(msg); } if (type1.mus.length !== type2.mus.length) { @@ -151,11 +187,55 @@ export const unify = (type1: MonoType, type2: MonoType): Substitution => { let s: Substitution = makeSubstitution({}); for (let i = 0; i < type1.mus.length; i++) { - s = s(unify(s(type1.mus[i]), s(type2.mus[i]))); + s = s( + unify(s(type1.mus[i]), s(type2.mus[i]), expr, addHist(path1, type1), addHist(path2, type2)) + ); } return s; }; +const formatUnificationError = ( + type1: TypeFunctionApplication, + type2: TypeFunctionApplication, + expr: Expression, + _path1: ExplainPath, + _path2: ExplainPath +): string => { + // console.dir({type1, type2, expr, _path1, _path2}, {depth: Infinity}); + if (expr.type === 'app') { + const msg = `"${reprExpression(expr.e1)}" expects "${reprExpression(expr.e2)}" to be a ${ + type1.C + }, but it is a ${type2.C}`; + return msg; + } + throw new Error(`Unexpected expression type ${expr}`); +}; + +export const reprExpression = (expr: Expression): string => { + if (expr.type === 'app') return reprExpression(expr.e1); + if (expr.type === 'var') return expr.x.startsWith('var: ') ? expr.x.substring(5) : expr.x; + if (expr.type === 'num') return expr.x.toString(); + if (expr.type === 'str') return expr.x.toString(); + if (expr.type === 'abs') return `{"${expr.x}": ${reprExpression(expr.e)}}`; + if (expr.type === 'let') + return `"${expr.x}" = ${reprExpression(expr.e1)} in ${reprExpression(expr.e2)}`; + ((_: never): never => { + throw new Error(`Unexpected expression type ${expr}`); + })(expr); +}; + +const addHist = (history: ExplainPath, root: MonoType): ExplainPath => { + const _addHist = (root: MonoType): ExplainPath => { + if (root.type === 'ty-app' || !root.explain) return []; + const [ty, _explain] = root.explain; + const explPath = _addHist(ty); + // root.explain = [explPath, _explain] + return [root, ...explPath]; + }; + // and return the new history + return history.concat(_addHist(root)); +}; + const contains = (value: MonoType, type2: TypeVariable): boolean => { if (value.type === 'ty-var') { return value.a === type2.a; @@ -165,5 +245,7 @@ const contains = (value: MonoType, type2: TypeVariable): boolean => { return value.mus.some(t => contains(t, type2)); } - throw new Error('Unknown argument passed to substitution'); + ((_: never): never => { + throw new Error('Unknown argument passed to substitution'); + })(value); }; diff --git a/src/m.ts b/src/m.ts index b35ccd4..1d7f110 100644 --- a/src/m.ts +++ b/src/m.ts @@ -10,17 +10,29 @@ export const M = (typEnv: Context, expr: Expression, type: MonoType): Substituti const value = typEnv[expr.x]; if (value === undefined) throw new Error(`Undefined variable: ${expr.x}`); - return unify(type, instantiate(value)); + return unify(type, instantiate(value), expr); + } + + if (expr.type === 'num') { + return unify(type, {type: 'ty-app', C: 'Number', mus: []}, expr); + } + + if (expr.type === 'str') { + return unify(type, {type: 'ty-app', C: 'String', mus: []}, expr); } if (expr.type === 'abs') { const beta1 = newTypeVar(); const beta2 = newTypeVar(); - const s1 = unify(type, { - type: 'ty-app', - C: '->', - mus: [beta1, beta2], - }); + const s1 = unify( + type, + { + type: 'ty-app', + C: '->', + mus: [beta1, beta2], + }, + expr + ); const s2 = M( makeContext({ ...s1(typEnv), diff --git a/src/models.ts b/src/models.ts index 46e4da1..aa1c097 100644 --- a/src/models.ts +++ b/src/models.ts @@ -69,8 +69,15 @@ export type TypeFunction = '->' | 'Array' | 'Boolean' | 'Number' | 'String' | 'N export interface TypeVariable { type: 'ty-var'; a: string; + explain?: [MonoType, TypeExplanation]; // Optional explanation } +export type TypeExplanation = + | {type: 'ExplainAlias'; path1: ExplainPath; path2: ExplainPath; expr: Expression} + | {type: 'ExplainInstan'; path: ExplainPath; expr: Expression}; + +export type ExplainPath = TypeVariable[]; + export interface TypeFunctionApplication { type: 'ty-app'; C: TypeFunction; diff --git a/src/parser.ts b/src/parser.ts index ed3cfd1..6334416 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -97,16 +97,18 @@ export const defaultContext = makeContext({ // TODO: overload with sum type encoding // "in": f(string, string, bool), cat: f(array(string), string), + // add a unary cat to cast to string + '1-ary cat': forall([a], f(a, string)), substr: f(string, number, string), '3-ary substr': f(string, number, number, string), // Miscellaneous log: forall([a], f(a, a)), }); -type JsonLogicParameter = JsonLogicExpression | boolean | string | number | JsonLogicParameter[]; -type JsonLogicExpression = - | {[operation: string]: JsonLogicParameter[]} // normal form e.g. {"var": ["path.in.data"]} - | {[operation: string]: JsonLogicParameter}; // unary operation e.g. {"var": "path.in.data"} +type JsonLogicExpression = JsonLogicOperation | boolean | string | number | JsonLogicExpression[]; +type JsonLogicOperation = + | {[operation: string]: JsonLogicExpression[]} // normal form e.g. {"var": ["path.in.data"]} + | {[operation: string]: JsonLogicExpression}; // unary operation e.g. {"var": "path.in.data"} /** * @param json - (malformed?) JsonLogic rule @@ -345,11 +347,15 @@ export const parseJsonLogicExpression = ( {[varName]: {type: 'ty-var', a: varName}, ...context}, {type: 'var', x: varName}, ]; - } else if (new Set(['<', '<=', 'substr']).has(operation) && args.length === 3) { + } else if (['<', '<=', 'substr'].includes(operation) && args.length === 3) { operation = `3-ary ${operation}`; - } else if (new Set(['-', '+']).has(operation) && args.length === 1) { + } else if (['-', '+'].includes(operation) && args.length === 1) { operation = `1-ary ${operation}`; } else if ((operation === '+' || operation === '*') && args.length != 2) { + if (args.length == 0) + throw Error( + `This ${operation} operation is incomplete ${repr(json)}.\nIt needs some arguments.` + ); // NB unary + should be handled already... // monomorphise sum and product versions of + and * operation = `${args.length}-ary ${operation}`; @@ -392,7 +398,7 @@ export const parseJsonLogicExpression = ( a ); } - } else if (new Set(['map', 'filter', 'all', 'some', 'none']).has(operation)) { + } else if (['map', 'filter', 'all', 'some', 'none'].includes(operation)) { const [newContext, [arrayExp, e2]] = parseValues(args, context); return [ newContext, @@ -413,7 +419,7 @@ export const parseJsonLogicExpression = ( initialAccumulator, ]), ]; - } else if (new Set(['cat', 'merge', 'missing', 'min', 'max']).has(operation)) { + } else if (['cat', 'merge', 'missing', 'min', 'max'].includes(operation)) { // pass all params for n-adic functions as a single array const [newContext, arrayExp] = parseValue(args, context); return [newContext, apply([{type: 'var', x: operation}, arrayExp])]; @@ -490,3 +496,33 @@ export const parseContext = ( .map(([key, value]) => parseContext(value, context, [...path, key])) .reduce((acc, curr) => ({...acc, ...curr}), context); }; + +export const stringify = (expr: Expression): JsonLogicExpression => { + switch (expr.type) { + case 'num': + return expr.x; + case 'str': + return expr.x; + case 'var': + const name = expr.x.replace(/^var: /, ''); + return name === '[]' ? [] : {var: name}; // [] is the cons cell "nil" + case 'app': + const {e1, e2} = expr; + switch (e1.type) { + case 'var': + const op = e1.x.replace(/^\d+-ary /, ''); + return {[op]: [stringify(e2)]}; + case 'app': + if (e1.e1.type === 'var' && e1.e1.x == 'cons') + return [stringify(e1.e2)].concat(stringify(e2)); // handle cons cell + return Object.fromEntries( + Object.entries(stringify(e1)).map(([op, arg]) => [op, [...arg, stringify(e2)]]) + ); + } + } + const unexpectedExpression = JSON.stringify(expr); + ((_: never): never => { + throw unexpectedExpression; + // @ts-ignore + })(expr); +}; diff --git a/src/w.ts b/src/w.ts index d2e5682..f08c18d 100644 --- a/src/w.ts +++ b/src/w.ts @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: MIT import {Substitution, generalise, instantiate, makeSubstitution, newTypeVar, unify} from './helper'; -import {Context, Expression, MonoType, makeContext} from './models'; +import {Context, Expression, MonoType, PolyType, makeContext} from './models'; export const W = (typEnv: Context, expr: Expression): [Substitution, MonoType] => { if (expr.type === 'var') { @@ -44,12 +44,27 @@ export const W = (typEnv: Context, expr: Expression): [Substitution, MonoType] = const [s2, t2] = W(s1(typEnv), expr.e2); const beta = newTypeVar(); - const s3 = unify(s2(t1), { - type: 'ty-app', - C: '->', - mus: [t2, beta], - }); - return [s3(s2(s1)), s3(beta)]; + try { + const s3 = unify( + s2(t1), + { + type: 'ty-app', + C: '->', + mus: [t2, beta], + }, + expr + ); + return [s3(s2(s1)), s3(beta)]; + } catch (error) { + const hasExplanation = ([k, t]: [string, PolyType]): boolean => + k.startsWith('var') && t.type == 'ty-var' && t.explain != undefined; + const withExpl = Object.fromEntries(Object.entries(typEnv).filter(hasExplanation)); + withExpl; + // TODO expr here !== expr from the throw site in unify!! + // reversing explainPaths is tricky... stringifying the larger expr and the sub expr from unify + // will probably provide enough context. + throw error; + } } if (expr.type === 'let') { diff --git a/test-d/__snapshots__/jsonlogic.test.ts.snap b/test-d/__snapshots__/jsonlogic.test.ts.snap new file mode 100644 index 0000000..b362dbb --- /dev/null +++ b/test-d/__snapshots__/jsonlogic.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Test against JsonLogic suite can detect {"!==":[1,"1"]}, with context {}, does not typecheck 1`] = `""!==" expects "1" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {"*":["1",1]}, with context {}, does not typecheck 1`] = `""*" expects "1" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {"+":["1",1]}, with context {}, does not typecheck 1`] = `""+" expects "1" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {"/":["1",1]}, with context {}, does not typecheck 1`] = `""/" expects "1" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {"<":["1",2]}, with context {}, does not typecheck 1`] = `""<" expects "1" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {"<=":["1",2]}, with context {}, does not typecheck 1`] = `""<=" expects "1" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {"===":[0,"0"]}, with context null, does not typecheck 1`] = `""===" expects "0" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {"===":[1,"1"]}, with context {}, does not typecheck 1`] = `""===" expects "1" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {">":["2",1]}, with context {}, does not typecheck 1`] = `"">" expects "2" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {">=":["2",1]}, with context {}, does not typecheck 1`] = `"">=" expects "2" to be a Number, but it is a String"`; + +exports[`Test against JsonLogic suite can detect {"-":["1",1]}, with context {}, does not typecheck 1`] = `""-" expects "1" to be a Number, but it is a String"`; diff --git a/test-d/__snapshots__/stringify.test.ts.snap b/test-d/__snapshots__/stringify.test.ts.snap new file mode 100644 index 0000000..a1abf3c --- /dev/null +++ b/test-d/__snapshots__/stringify.test.ts.snap @@ -0,0 +1,6 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`"+" operations not enough arguments 1`] = ` +"This + operation is incomplete {"+":[]}. +It needs some arguments." +`; diff --git a/test-d/jsonlogic.test.ts b/test-d/jsonlogic.test.ts index 2f98ea9..51587b0 100644 --- a/test-d/jsonlogic.test.ts +++ b/test-d/jsonlogic.test.ts @@ -1,3 +1,6 @@ +import {Substitution, newTypeVar} from '../src/helper'; +import {M} from '../src/m'; +import {MonoType} from '../src/models'; import type {JSONValue} from '../src/parser'; import {parseContext, parseJsonLogicExpression} from '../src/parser'; import {W} from '../src/w'; @@ -13,11 +16,21 @@ type ErrorCase = [rule: JSONValue, data: JSONValue, result: JSONValue, error: st const isErrorCase = (testCase: string | unknown[]): testCase is ErrorCase => Array.isArray(testCase) && testCase.length == 4; -const inferResultType = (jsonLogic: JSONValue, data: JSONValue): string => { +const inferResultType = (jsonLogic: JSONValue, data: JSONValue, use: 'W' | 'M' = 'W'): string => { const context = parseContext(data); - const [subsitution, t] = W(...parseJsonLogicExpression(jsonLogic, context)); - // TODO: get "var" from subsitution - return 'C' in t ? t.C : JSON.stringify([t.a, Object.keys(subsitution.raw)]); + let substitution: Substitution, t: MonoType; + if (use === 'W') { + [substitution, t] = W(...parseJsonLogicExpression(jsonLogic, context)); + return 'C' in t ? t.C : JSON.stringify([t.a, Object.keys(substitution.raw)]); + } else if (use === 'M') { + t = newTypeVar(); + substitution = M(...parseJsonLogicExpression(jsonLogic, context), t); + t = substitution(t); + return 'C' in t ? t.C : JSON.stringify([t.a, Object.keys(substitution.raw)]); + } + ((_: never): never => { + throw Error(); + })(use); }; const getType = (obj: unknown): 'Boolean' | 'Number' | 'String' | 'Array' | 'Null' | 'Object' => @@ -43,16 +56,57 @@ describe('Test against JsonLogic suite', () => { } ); it.each(cases.filter(isErrorCase))( - 'can detect %j, with context %j, does not typecheck with %j and raises some Error containing %j', + 'can detect %j, with context %j, does not typecheck', (rule: JSONValue, data: JSONValue, result: JSONValue, error: string) => { - if (error.startsWith("TODO")) - expect(() => inferResultType(rule, data)).toThrow("") - else if (error.startsWith("Valid")){ + if (error.startsWith('Valid')) { + // e.g. expressions like {"and": [1, 2]} are valid, but we infer them as Boolean + // not Number (the type of the 2 literal) const t = inferResultType(rule, data); expect(t).not.toBe(getType(result)); - } else - expect(() => inferResultType(rule, data)).toThrow(error) - - } + } else if (error == 'Snapshot') + expect(() => inferResultType(rule, data)).toThrowErrorMatchingSnapshot(); + else if (error.startsWith('TODO')) expect(() => inferResultType(rule, data)).toThrow(); + else expect(() => inferResultType(rule, data)).toThrow(error); + } ); + + test('{"+": [{"var": "a"}, "1"]} with a: "1" ', () => { + expect(() => inferResultType({'+': [{var: 'a'}, 1]}, {a: '1'})).toThrow( + `"+" expects "a" to be a Number, but it is a String` + ); + }); + test('{"+": [1, "1"]}', () => { + expect(() => inferResultType({'+': ['1', 1]}, {})).toThrow( + `"+" expects "1" to be a Number, but it is a String` + ); + }); + test('{">": ["2", 1]}', () => { + expect(() => inferResultType({'>': ['2', 1]}, {})).toThrowErrorMatchingInlineSnapshot( + `"">" expects "2" to be a Number, but it is a String"` + ); + }); + test('{"===": [{"var": ["items expression", [["value", "label"]]]}, {"var": "current_year"}]}', () => { + expect(() => + inferResultType( + { + '===': [ + { + if: [ + {'!!': [{var: 'items expression'}]}, + {var: 'items expression'}, + [['value', 'label']], + ], + }, + + {var: 'current_year'}, + ], + }, + + // {'===': [{var: ['items expression', [['value', 'label']]]}, {var: 'current_year'}]}, + {current_year: 2023} + ) + ).toThrowErrorMatchingInlineSnapshot( + `""===" expects "current_year" to be a Array, but it is a Number"` + ); // TODO improve this. While correct, user can't change current_year + }); }); diff --git a/test-d/stringify.test.ts b/test-d/stringify.test.ts new file mode 100644 index 0000000..955719f --- /dev/null +++ b/test-d/stringify.test.ts @@ -0,0 +1,106 @@ +import fc from 'fast-check'; + +import {JSONValue, parseJsonLogicExpression, stringify} from '../src/parser'; + +const number = (): fc.Arbitrary => fc.oneof(fc.integer(), fc.float()); + +test('number literals', () => { + fc.assert( + fc.property(number(), logic => { + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual(logic); + }) + ); +}); + +test('string literals', () => { + fc.assert( + fc.property(fc.string(), logic => { + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual(logic); + }) + ); +}); + +test('"var" expressions', () => { + fc.assert( + fc.property(fc.string(), name => { + const logic = {var: name}; + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual(logic); + }) + ); +}); + +describe('"+" operations', () => { + test('binary', () => { + fc.assert( + fc.property(number(), number(), (x, y) => { + const logic = {'+': [x, y]}; + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual(logic); + }) + ); + }); + test('unary cast', () => { + fc.assert( + fc.property(number(), x => { + const logic = {'+': `${x}`}; // {"+": "x"} + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual({'+': [`${x}`]}); + }) + ); + }); + + test('n-ary sum', () => { + fc.assert( + fc.property(fc.array(number(), {minLength: 1}), xs => { + const logic = {'+': xs}; + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual(logic); + }) + ); + }); + test('not enough arguments', () => { + const logic = {'+': []}; + expect(() => parseJsonLogicExpression(logic)).toThrowErrorMatchingSnapshot(); + }); +}); + +test('array literal [1, 2]', () => { + const logic = [1, 2]; + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual(logic); +}); + +test('array literal [1]', () => { + const logic = [1]; + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual(logic); +}); + +test('array literal []', () => { + const logic: number[] = []; + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual(logic); +}); + +test('arrays of literals', () => { + fc.assert( + fc.property(nestedArrays(), logic => { + const [_ctx, expr] = parseJsonLogicExpression(logic); + expect(stringify(expr)).toEqual(logic); + }) + ); +}); + +const nestedArrays = (): fc.Arbitrary => + fc.letrec(tie => ({ + nestedArray: fc.array( + fc.oneof( + number(), // Number literal + fc.string(), // String literal + tie('nestedArray') + ) + ), + })).nestedArray as fc.Arbitrary; // blegh diff --git a/test-d/tests.json b/test-d/tests.json index af73d57..07801f6 100644 --- a/test-d/tests.json +++ b/test-d/tests.json @@ -13,33 +13,33 @@ [ {"==":[1,"1"]}, {}, true ], [ {"==":[1,2]}, {}, false ], [ {"===":[1,1]}, {}, true ], - [ {"===":[1,"1"]}, {}, false, "Number and String" ], + [ {"===":[1,"1"]}, {}, false, "Snapshot" ], [ {"===":[1,2]}, {}, false ], [ {"!=":[1,2]}, {}, true ], [ {"!=":[1,1]}, {}, false ], [ {"!=":[1,"1"]}, {}, false ], [ {"!==":[1,2]}, {}, true ], [ {"!==":[1,1]}, {}, false ], - [ {"!==":[1,"1"]}, {}, true, "Number and String" ], + [ {"!==":[1,"1"]}, {}, true, "Snapshot" ], [ {">":[2,1]}, {}, true ], [ {">":[1,1]}, {}, false ], [ {">":[1,2]}, {}, false ], - [ {">":["2",1]}, {}, true, "TODO" ], + [ {">":["2",1]}, {}, true, "Snapshot" ], [ {">=":[2,1]}, {}, true ], [ {">=":[1,1]}, {}, true ], [ {">=":[1,2]}, {}, false ], - [ {">=":["2",1]}, {}, true, "TODO" ], + [ {">=":["2",1]}, {}, true, "Snapshot" ], [ {"<":[2,1]}, {}, false ], [ {"<":[1,1]}, {}, false ], [ {"<":[1,2]}, {}, true ], - [ {"<":["1",2]}, {}, true, "TODO" ], + [ {"<":["1",2]}, {}, true, "Snapshot" ], [ {"<":[1,2,3]}, {}, true ], [ {"<":[1,1,3]}, {}, false ], [ {"<":[1,4,3]}, {}, false ], [ {"<=":[2,1]}, {}, false ], [ {"<=":[1,1]}, {}, true ], [ {"<=":[1,2]}, {}, true ], - [ {"<=":["1",2]}, {}, true, "TODO" ], + [ {"<=":["1",2]}, {}, true, "Snapshot" ], [ {"<=":[1,2,3]}, {}, true ], [ {"<=":[1,4,3]}, {}, false ], [ {"!":[false]}, {}, true ], @@ -97,18 +97,18 @@ [ {"+":[1,2]}, {}, 3 ], [ {"+":[2,2,2]}, {}, 6 ], [ {"+":[1]}, {}, 1 ], - [ {"+":["1",1]}, {}, 2, "TODO" ], + [ {"+":["1",1]}, {}, 2, "Snapshot" ], [ {"*":[3,2]}, {}, 6 ], [ {"*":[2,2,2]}, {}, 8 ], [ {"*":[1]}, {}, 1 ], - [ {"*":["1",1]}, {}, 1, "TODO" ], + [ {"*":["1",1]}, {}, 1, "Snapshot" ], [ {"-":[2,3]}, {}, -1 ], [ {"-":[3,2]}, {}, 1 ], [ {"-":[3]}, {}, -3 ], - [ {"-":["1",1]}, {}, 0, "TODO" ], + [ {"-":["1",1]}, {}, 0, "Snapshot" ], [ {"/":[4,2]}, {}, 2 ], [ {"/":[2,4]}, {}, 0.5 ], - [ {"/":["1",1]}, {}, 1, "TODO" ], + [ {"/":["1",1]}, {}, 1, "Snapshot" ], "Substring", [{"substr":["jsonlogic", 4]}, null, "logic"], @@ -129,9 +129,9 @@ [{"merge":[[1, 2], [3]]}, null, [1,2,3]], [{"merge":[[1], [2, 3]]}, null, [1,2,3]], "Given non-array arguments, merge converts them to arrays", - [{"merge":1}, null, [1], "TODO: merge doensn't cast"], - [{"merge":[1,2]}, null, [1,2], "TODO: merge doensn't cast"], - [{"merge":[1,[2]]}, null, [1,2], "TODO: merge doensn't cast"], + [{"merge":1}, null, [1], "TODO: merge doesn't cast"], + [{"merge":[1,2]}, null, [1,2], "TODO: merge doesn't cast"], + [{"merge":[1,[2]]}, null, [1,2], "TODO: merge doesn't cast"], "Too few args", [{"if":[]}, null, null, "incomplete"], @@ -156,7 +156,7 @@ [{"if":[ "0", "apple", "banana"]}, null, "apple"], "You can cast a string to numeric with a unary + ", - [{"===":[0,"0"]}, null, false, "Number and String" ], + [{"===":[0,"0"]}, null, false, "Snapshot" ], [{"===":[0,{"+":"0"}]}, null, true], [{"if":[ {"+":"0"}, "apple", "banana"]}, null, "banana"], [{"if":[ {"+":"1"}, "apple", "banana"]}, null, "apple"], @@ -170,7 +170,7 @@ "Truthy and falsy definitions matter in Boolean operations", [{"!" : [ [] ]}, {}, true], [{"!!" : [ [] ]}, {}, false], - [{"and" : [ [], true ]}, {}, [], "Valid, but not and Array" ], + [{"and" : [ [], true ]}, {}, [], "Valid, but not an Array" ], [{"or" : [ [], true ]}, {}, true ], [{"!" : [ 0 ]}, {}, true], @@ -294,14 +294,14 @@ {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} }, {"financing":true}, ["vin","apr"], - "TODO: merge doensn't cast \"vin\" to [\"vin\"]" + "TODO: merge doesn't cast \"vin\" to [\"vin\"]" ], [ {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} }, {"financing":false}, ["vin"], - "TODO: merge doensn't cast \"vin\" to [\"vin\"]" + "TODO: merge doesn't cast \"vin\" to [\"vin\"]" ], "Filter, map, all, none, and some",