From 0864fce81e176467ee2a5f619d03e9ad2a8a4001 Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Thu, 5 Mar 2026 07:42:31 -0800 Subject: [PATCH 1/3] added new operators and conditions to match er server updates --- src/common/types.ts | 9 +- src/v2/conditions.ts | 285 +++++++++++++++++++++++++++++++++++++------ src/v2/index.ts | 7 +- 3 files changed, 263 insertions(+), 38 deletions(-) diff --git a/src/common/types.ts b/src/common/types.ts index aef6b0e..436b267 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -95,13 +95,18 @@ export type V2ConditionOperator = | 'CONTAINS' | 'DOES_NOT_HAVE_INPUT' | 'HAS_INPUT' - | 'INPUT_IS_EXACTLY'; + | 'INPUT_IS_EXACTLY' + | 'IS_CONTAINED_BY' + | 'IS_EMPTY' + | 'IS_EXACTLY' + | 'IS_NOT_CONTAINED_BY' + | 'IS_NOT_EMPTY'; export interface V2Condition { field: string; id: string; operator: V2ConditionOperator; - value?: string | number | boolean | null; + value?: string | string[] | number | boolean | null; } export interface V2Section { diff --git a/src/v2/conditions.ts b/src/v2/conditions.ts index 73fac32..15d08f6 100644 --- a/src/v2/conditions.ts +++ b/src/v2/conditions.ts @@ -7,27 +7,85 @@ import { /** * Builds a JSON Schema for the CONTAINS operator. - * Matches strings that contain the specified value as a substring. + * Handles array, object, and string field types via anyOf. + * When value is an array, all values must be contained; the string branch + * becomes unsatisfiable (const: null) since a string can't contain multiple + * independent required values simultaneously. */ -export const buildContainsSchema = (value: string): Record => { +export const buildContainsSchema = (value: string | string[]): Record => { + const values = Array.isArray(value) ? value : [value]; + + if (values.length === 1) { + const v = values[0]; + return { + anyOf: [ + { + allOf: [{ contains: { const: v } }], + type: 'array', + }, + { + required: [v], + type: 'object', + }, + { + pattern: v, + type: 'string', + }, + ], + }; + } + return { - pattern: value, - type: 'string', + anyOf: [ + { + allOf: values.map((v) => ({ contains: { const: v } })), + type: 'array', + }, + { + required: values, + type: 'object', + }, + { + // Impossible branch: a string cannot satisfy multiple simultaneous contains requirements + const: null, + type: 'string', + }, + ], }; }; /** - * Builds a JSON Schema for the DOES_NOT_HAVE_INPUT operator. + * Builds a JSON Schema for the IS_EMPTY operator. + * Returns a root-level schema (not field-scoped) because it must check for + * field absence using `not: { required: [fieldName] }`. */ -export const buildDoesNotHaveInputSchema = (): Record => { +export const buildIsEmptySchema = (fieldName: string): Record => { return { - not: buildHasInputSchema(), + anyOf: [ + { not: { required: [fieldName] } }, + { + properties: { [fieldName]: { maxItems: 0, type: 'array' } }, + required: [fieldName], + }, + { + properties: { [fieldName]: { type: 'null' } }, + required: [fieldName], + }, + { + properties: { [fieldName]: { maxProperties: 0, type: 'object' } }, + required: [fieldName], + }, + { + properties: { [fieldName]: { maxLength: 0, type: 'string' } }, + required: [fieldName], + }, + ], }; }; /** - * Builds a JSON Schema for the HAS_INPUT operator. - * Matches when the field has meaningful input (non-null, non-empty). + * Builds a JSON Schema for the IS_NOT_EMPTY / HAS_INPUT operator. + * Matches when the field is present and has a meaningful (non-null, non-empty) value. */ export const buildHasInputSchema = (): Record => { return { @@ -47,9 +105,78 @@ export const buildHasInputSchema = (): Record => { }; /** - * Builds a JSON Schema for the INPUT_IS_EXACTLY operator. + * Builds a JSON Schema for the DOES_NOT_HAVE_INPUT operator (legacy). + */ +export const buildDoesNotHaveInputSchema = (): Record => { + return { + not: buildHasInputSchema(), + }; +}; + +/** + * Builds a JSON Schema for the IS_EXACTLY operator. + * Handles array, boolean, number, object, and string field types via anyOf. + * When value is an array, non-array/object branches become unsatisfiable (const: null). + * When value is a single string parseable as boolean/number, those branches use the parsed value. + */ +export const buildIsExactlySchema = (value: string | string[]): Record => { + const values = Array.isArray(value) ? value : [value]; + const isSingle = values.length === 1; + + const arrayBranch: Record = { + allOf: values.map((v) => ({ contains: { const: v } })), + maxItems: values.length, + type: 'array', + }; + + // Boolean branch — meaningful only for single value parseable as boolean + let booleanConst: boolean | null = null; + if (isSingle) { + const lower = values[0].toLowerCase(); + if (lower === 'true') booleanConst = true; + else if (lower === 'false') booleanConst = false; + } + const booleanBranch = + booleanConst !== null + ? { const: booleanConst, type: 'boolean' } + : { const: null, type: 'boolean' }; + + // Number branch — meaningful only for single value parseable as number + let numberConst: number | null = null; + if (isSingle) { + const parsed = Number(values[0]); + if (!isNaN(parsed) && values[0] !== '') numberConst = parsed; + } + const numberBranch = + numberConst !== null + ? { const: numberConst, type: 'number' } + : { const: null, type: 'number' }; + + // Object branch — all values become required property keys + const objectProperties: Record> = {}; + values.forEach((v) => { + objectProperties[v] = {}; + }); + const objectBranch: Record = { + properties: objectProperties, + required: values, + type: 'object', + unevaluatedProperties: false, + }; + + // String branch — meaningful only for single value + const stringBranch = isSingle + ? { const: values[0], type: 'string' } + : { const: null, type: 'string' }; + + return { + anyOf: [arrayBranch, booleanBranch, numberBranch, objectBranch, stringBranch], + }; +}; + +/** + * Builds a JSON Schema for the INPUT_IS_EXACTLY operator (legacy). * Matches when the field value equals the specified value exactly. - * Uses `enum` for simple matching without type constraints. */ export const buildInputIsExactlySchema = ( value: string | number | boolean | null, @@ -61,10 +188,8 @@ export const buildInputIsExactlySchema = ( return { const: value }; } if (typeof value === 'number') { - // Allow both number and string representation return { enum: [value, String(value)] }; } - // String value - also allow numeric match if the string is a valid number const numValue = Number(value); if (!isNaN(numValue) && value !== '') { return { enum: [numValue, value] }; @@ -72,16 +197,66 @@ export const buildInputIsExactlySchema = ( return { const: value }; }; +/** + * Builds a JSON Schema for the IS_CONTAINED_BY operator. + * The condition is fulfilled when all elements of the field are included + * within the specified set of values. + */ +export const buildIsContainedBySchema = (value: string | string[]): Record => { + const values = Array.isArray(value) ? value : [value]; + return { + anyOf: [ + { items: { enum: values }, minItems: 1, type: 'array' }, + { minProperties: 1, propertyNames: { enum: values }, type: 'object' }, + { enum: values, type: 'string' }, + ], + }; +}; + +/** + * Builds a JSON Schema for the IS_NOT_CONTAINED_BY operator. + * The condition is fulfilled when at least one element of the field is not + * included within the specified set of values. + */ +export const buildIsNotContainedBySchema = ( + value: string | string[], +): Record => { + const values = Array.isArray(value) ? value : [value]; + return { + anyOf: [ + { + allOf: [ + { minItems: 1, type: 'array' }, + { not: { items: { enum: values }, type: 'array' } }, + ], + }, + { + allOf: [ + { minProperties: 1, type: 'object' }, + { not: { propertyNames: { enum: values }, type: 'object' } }, + ], + }, + { not: { enum: values }, type: 'string' }, + ], + }; +}; + const VALID_OPERATORS: V2ConditionOperator[] = [ 'CONTAINS', 'DOES_NOT_HAVE_INPUT', 'HAS_INPUT', 'INPUT_IS_EXACTLY', + 'IS_CONTAINED_BY', + 'IS_EMPTY', + 'IS_EXACTLY', + 'IS_NOT_CONTAINED_BY', + 'IS_NOT_EMPTY', ]; /** - * Gets the operator schema for a condition (without field wrapper). - * Used internally by buildConditionSchema and buildSchemaBasedCondition. + * Gets the operator schema for a condition. + * Note: IS_EMPTY is root-scoped; callers that need field-scoped wrapping + * should use buildConditionSchema instead. */ export const getOperatorSchema = ( condition: V2Condition, @@ -93,21 +268,49 @@ export const getOperatorSchema = ( `CONTAINS operator requires a value for condition '${condition.id}'`, ); } - return buildContainsSchema(String(condition.value)); + return buildContainsSchema(condition.value as string | string[]); case 'DOES_NOT_HAVE_INPUT': return buildDoesNotHaveInputSchema(); case 'HAS_INPUT': + case 'IS_NOT_EMPTY': return buildHasInputSchema(); + case 'IS_EMPTY': + return buildIsEmptySchema(condition.field); + case 'INPUT_IS_EXACTLY': if (condition.value === undefined) { throw new Error( `INPUT_IS_EXACTLY operator requires a value for condition '${condition.id}'`, ); } - return buildInputIsExactlySchema(condition.value); + return buildInputIsExactlySchema(condition.value as string | number | boolean | null); + + case 'IS_EXACTLY': + if (condition.value === undefined || condition.value === null) { + throw new Error( + `IS_EXACTLY operator requires a value for condition '${condition.id}'`, + ); + } + return buildIsExactlySchema(condition.value as string | string[]); + + case 'IS_CONTAINED_BY': + if (condition.value === undefined || condition.value === null) { + throw new Error( + `IS_CONTAINED_BY operator requires a value for condition '${condition.id}'`, + ); + } + return buildIsContainedBySchema(condition.value as string | string[]); + + case 'IS_NOT_CONTAINED_BY': + if (condition.value === undefined || condition.value === null) { + throw new Error( + `IS_NOT_CONTAINED_BY operator requires a value for condition '${condition.id}'`, + ); + } + return buildIsNotContainedBySchema(condition.value as string | string[]); default: throw new Error( @@ -117,15 +320,18 @@ export const getOperatorSchema = ( }; /** - * Builds a JSON Schema for a single condition based on its operator. - * Wraps the operator schema in a properties structure for root scope validation. + * Builds a root-scoped JSON Schema for a single condition. + * IS_EMPTY contributes its root-level anyOf directly; all other operators + * are wrapped in a properties/required structure. */ export const buildConditionSchema = ( condition: V2Condition, ): Record => { - const operatorSchema = getOperatorSchema(condition); + if (condition.operator === 'IS_EMPTY') { + return buildIsEmptySchema(condition.field); + } - // Wrap in properties structure for the field (used with scope: "#") + const operatorSchema = getOperatorSchema(condition); return { properties: { [condition.field]: operatorSchema, @@ -137,10 +343,11 @@ export const buildConditionSchema = ( /** * Builds a JSONForms schema-based condition from one or more V2 conditions. * - * For single conditions: Uses field-specific scope (e.g., "#/properties/fieldName") - * which is the standard JSONForms pattern and most reliable across implementations. + * Single condition: + * - IS_EMPTY → root scope "#" (must check for field absence at object level) + * - All others → field scope "#/properties/fieldName" * - * For multiple conditions: Uses root scope "#" with allOf combining property schemas. + * Multiple conditions: root scope "#" with allOf combining all condition schemas. */ export const buildSchemaBasedCondition = ( conditions: V2Condition[], @@ -150,18 +357,23 @@ export const buildSchemaBasedCondition = ( } if (conditions.length === 1) { - // Single condition: use field-specific scope for better compatibility const condition = conditions[0]; + + if (condition.operator === 'IS_EMPTY') { + return { + scope: '#', + schema: buildIsEmptySchema(condition.field), + }; + } + return { scope: `#/properties/${condition.field}`, schema: getOperatorSchema(condition), }; } - // Multiple conditions: use root scope with allOf - const allOfSchemas = conditions.map((condition) => - buildConditionSchema(condition), - ); + // Multiple conditions: combine with allOf at root scope + const allOfSchemas = conditions.map((condition) => buildConditionSchema(condition)); return { scope: '#', @@ -173,7 +385,6 @@ export const buildSchemaBasedCondition = ( /** * Creates a complete JSONForms rule for a section with the given conditions. - * Uses SHOW effect by default. */ export const createSectionRule = (conditions: V2Condition[]): JSONFormsRule => { return { @@ -190,23 +401,27 @@ export const validateConditions = ( conditions: V2Condition[], fieldNames: string[], ): void => { + const requiresValue: V2ConditionOperator[] = [ + 'CONTAINS', + 'IS_EXACTLY', + 'IS_CONTAINED_BY', + 'IS_NOT_CONTAINED_BY', + ]; + for (const condition of conditions) { - // Validate operator if (!VALID_OPERATORS.includes(condition.operator)) { throw new Error(`Invalid operator '${condition.operator}'`); } - // Validate field exists if (!fieldNames.includes(condition.field)) { throw new Error(`Unknown field '${condition.field}'`); } - // Validate required values if ( - condition.operator === 'CONTAINS' && + requiresValue.includes(condition.operator) && (condition.value === undefined || condition.value === null) ) { - throw new Error(`CONTAINS operator requires a value`); + throw new Error(`${condition.operator} operator requires a value`); } if ( diff --git a/src/v2/index.ts b/src/v2/index.ts index c11fbd7..b94c2ce 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -4,9 +4,14 @@ export { generateUISchema } from './generateUISchema'; // Condition utilities export { buildConditionSchema, + buildContainsSchema, + buildIsContainedBySchema, + buildIsEmptySchema, + buildIsExactlySchema, + buildIsNotContainedBySchema, buildSchemaBasedCondition, createSectionRule, - validateConditions + validateConditions, } from './conditions'; // Schema utilities From 5aa0024f099497011c203567d63c65c18d1edf68 Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Thu, 5 Mar 2026 07:42:51 -0800 Subject: [PATCH 2/3] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0529ff3..b40782d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@earthranger/react-native-jsonforms-formatter", - "version": "2.0.0-beta.23", + "version": "2.0.0-beta.24", "description": "Converts JTD into JSON Schema ", "main": "./dist/bundle.js", "types": "./dist/index.d.ts", From f40a9176a3f8c7f1e2d4948d2cf616b78deeb339 Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Thu, 5 Mar 2026 18:20:19 -0800 Subject: [PATCH 3/3] add initial tests --- test/v2.conditions.test.ts | 751 +++++++++++++++++++++++++++++++++++++ 1 file changed, 751 insertions(+) create mode 100644 test/v2.conditions.test.ts diff --git a/test/v2.conditions.test.ts b/test/v2.conditions.test.ts new file mode 100644 index 0000000..b1e25db --- /dev/null +++ b/test/v2.conditions.test.ts @@ -0,0 +1,751 @@ +import { + buildConditionSchema, + buildContainsSchema, + buildDoesNotHaveInputSchema, + buildHasInputSchema, + buildInputIsExactlySchema, + buildIsContainedBySchema, + buildIsEmptySchema, + buildIsExactlySchema, + buildIsNotContainedBySchema, + buildSchemaBasedCondition, + createSectionRule, + getOperatorSchema, + validateConditions, +} from '../src/v2/conditions'; +import { V2Condition } from '../src/common/types'; + +// --------------------------------------------------------------------------- +// buildContainsSchema +// --------------------------------------------------------------------------- + +describe('buildContainsSchema', () => { + describe('single string value', () => { + const schema = buildContainsSchema('elephant'); + + it('returns anyOf with three branches', () => { + expect((schema as any).anyOf).toHaveLength(3); + }); + + it('array branch uses contains', () => { + expect((schema as any).anyOf[0]).toMatchObject({ + type: 'array', + allOf: [{ contains: { const: 'elephant' } }], + }); + }); + + it('object branch uses required', () => { + expect((schema as any).anyOf[1]).toMatchObject({ + type: 'object', + required: ['elephant'], + }); + }); + + it('string branch uses pattern', () => { + expect((schema as any).anyOf[2]).toMatchObject({ + type: 'string', + pattern: 'elephant', + }); + }); + }); + + describe('array of values', () => { + const schema = buildContainsSchema(['elephant', 'lion']); + + it('array branch has one contains entry per value', () => { + const arrayBranch = (schema as any).anyOf[0]; + expect(arrayBranch.type).toBe('array'); + expect(arrayBranch.allOf).toContainEqual({ contains: { const: 'elephant' } }); + expect(arrayBranch.allOf).toContainEqual({ contains: { const: 'lion' } }); + }); + + it('object branch requires all values', () => { + expect((schema as any).anyOf[1]).toMatchObject({ + type: 'object', + required: ['elephant', 'lion'], + }); + }); + + it('string branch is unsatisfiable (const: null)', () => { + expect((schema as any).anyOf[2]).toMatchObject({ + type: 'string', + const: null, + }); + }); + }); + + it('single-element array behaves the same as single string', () => { + const fromString = buildContainsSchema('elephant'); + const fromArray = buildContainsSchema(['elephant']); + // Both should produce identical anyOf (single-value path) + expect((fromString as any).anyOf[0]).toMatchObject((fromArray as any).anyOf[0]); + expect((fromString as any).anyOf[2]).toMatchObject({ type: 'string', pattern: 'elephant' }); + expect((fromArray as any).anyOf[2]).toMatchObject({ type: 'string', pattern: 'elephant' }); + }); +}); + +// --------------------------------------------------------------------------- +// buildIsEmptySchema +// --------------------------------------------------------------------------- + +describe('buildIsEmptySchema', () => { + const schema = buildIsEmptySchema('injured_animal') as any; + + it('returns an anyOf with 5 branches', () => { + expect(schema.anyOf).toHaveLength(5); + }); + + it('first branch: field not required (absent)', () => { + expect(schema.anyOf[0]).toEqual({ not: { required: ['injured_animal'] } }); + }); + + it('second branch: empty array', () => { + expect(schema.anyOf[1]).toMatchObject({ + properties: { injured_animal: { maxItems: 0, type: 'array' } }, + required: ['injured_animal'], + }); + }); + + it('third branch: null value', () => { + expect(schema.anyOf[2]).toMatchObject({ + properties: { injured_animal: { type: 'null' } }, + required: ['injured_animal'], + }); + }); + + it('fourth branch: empty object', () => { + expect(schema.anyOf[3]).toMatchObject({ + properties: { injured_animal: { maxProperties: 0, type: 'object' } }, + required: ['injured_animal'], + }); + }); + + it('fifth branch: empty string', () => { + expect(schema.anyOf[4]).toMatchObject({ + properties: { injured_animal: { maxLength: 0, type: 'string' } }, + required: ['injured_animal'], + }); + }); +}); + +// --------------------------------------------------------------------------- +// buildHasInputSchema / buildDoesNotHaveInputSchema +// --------------------------------------------------------------------------- + +describe('buildHasInputSchema', () => { + const schema = buildHasInputSchema() as any; + + it('uses allOf with not-null + anyOf of non-empty types', () => { + expect(schema.allOf).toHaveLength(2); + expect(schema.allOf[0]).toEqual({ not: { type: 'null' } }); + }); + + it('inner anyOf covers array, boolean, number, object, string', () => { + const inner = schema.allOf[1].anyOf; + expect(inner).toContainEqual({ type: 'array', minItems: 1 }); + expect(inner).toContainEqual({ type: 'boolean' }); + expect(inner).toContainEqual({ type: 'number' }); + expect(inner).toContainEqual({ type: 'object', minProperties: 1 }); + expect(inner).toContainEqual({ type: 'string', minLength: 1 }); + }); +}); + +describe('buildDoesNotHaveInputSchema', () => { + it('wraps buildHasInputSchema in a not', () => { + const schema = buildDoesNotHaveInputSchema() as any; + expect(schema).toMatchObject({ not: buildHasInputSchema() }); + }); +}); + +// --------------------------------------------------------------------------- +// buildIsExactlySchema +// --------------------------------------------------------------------------- + +describe('buildIsExactlySchema', () => { + describe('single plain string ("elephant")', () => { + const schema = buildIsExactlySchema('elephant') as any; + + it('array branch: contains + maxItems 1', () => { + expect(schema.anyOf[0]).toMatchObject({ + type: 'array', + allOf: [{ contains: { const: 'elephant' } }], + maxItems: 1, + }); + }); + + it('boolean branch: impossible (const: null)', () => { + expect(schema.anyOf[1]).toEqual({ const: null, type: 'boolean' }); + }); + + it('number branch: impossible (const: null)', () => { + expect(schema.anyOf[2]).toEqual({ const: null, type: 'number' }); + }); + + it('object branch: properties + required + unevaluatedProperties', () => { + expect(schema.anyOf[3]).toMatchObject({ + type: 'object', + properties: { elephant: {} }, + required: ['elephant'], + unevaluatedProperties: false, + }); + }); + + it('string branch: exact const', () => { + expect(schema.anyOf[4]).toEqual({ const: 'elephant', type: 'string' }); + }); + }); + + describe('single boolean-parseable string ("true")', () => { + const schema = buildIsExactlySchema('true') as any; + + it('boolean branch resolves to true', () => { + expect(schema.anyOf[1]).toEqual({ const: true, type: 'boolean' }); + }); + + it('number branch is still impossible', () => { + expect(schema.anyOf[2]).toEqual({ const: null, type: 'number' }); + }); + + it('string branch: const is "true"', () => { + expect(schema.anyOf[4]).toEqual({ const: 'true', type: 'string' }); + }); + }); + + describe('single boolean-parseable string ("false")', () => { + it('boolean branch resolves to false', () => { + const schema = buildIsExactlySchema('false') as any; + expect(schema.anyOf[1]).toEqual({ const: false, type: 'boolean' }); + }); + }); + + describe('single number-parseable string ("42")', () => { + const schema = buildIsExactlySchema('42') as any; + + it('number branch resolves to 42', () => { + expect(schema.anyOf[2]).toEqual({ const: 42, type: 'number' }); + }); + + it('boolean branch is still impossible', () => { + expect(schema.anyOf[1]).toEqual({ const: null, type: 'boolean' }); + }); + }); + + describe('array of strings', () => { + const schema = buildIsExactlySchema(['elephant', 'lion']) as any; + + it('array branch: all contains + maxItems equals count', () => { + expect(schema.anyOf[0]).toMatchObject({ + type: 'array', + allOf: [{ contains: { const: 'elephant' } }, { contains: { const: 'lion' } }], + maxItems: 2, + }); + }); + + it('boolean branch: impossible', () => { + expect(schema.anyOf[1]).toEqual({ const: null, type: 'boolean' }); + }); + + it('number branch: impossible', () => { + expect(schema.anyOf[2]).toEqual({ const: null, type: 'number' }); + }); + + it('object branch: all values as properties', () => { + expect(schema.anyOf[3]).toMatchObject({ + type: 'object', + properties: { elephant: {}, lion: {} }, + required: ['elephant', 'lion'], + unevaluatedProperties: false, + }); + }); + + it('string branch: impossible', () => { + expect(schema.anyOf[4]).toEqual({ const: null, type: 'string' }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// buildInputIsExactlySchema (legacy) +// --------------------------------------------------------------------------- + +describe('buildInputIsExactlySchema', () => { + it('null → type null', () => { + expect(buildInputIsExactlySchema(null)).toEqual({ type: 'null' }); + }); + + it('boolean → const boolean', () => { + expect(buildInputIsExactlySchema(true)).toEqual({ const: true }); + expect(buildInputIsExactlySchema(false)).toEqual({ const: false }); + }); + + it('number → enum with number and string form', () => { + expect(buildInputIsExactlySchema(3)).toEqual({ enum: [3, '3'] }); + }); + + it('numeric string → enum with number and string form', () => { + expect(buildInputIsExactlySchema('42')).toEqual({ enum: [42, '42'] }); + }); + + it('non-numeric string → const string', () => { + expect(buildInputIsExactlySchema('elephant')).toEqual({ const: 'elephant' }); + }); +}); + +// --------------------------------------------------------------------------- +// buildIsContainedBySchema +// --------------------------------------------------------------------------- + +describe('buildIsContainedBySchema', () => { + describe('with an array of values', () => { + const schema = buildIsContainedBySchema(['elephant', 'lion', 'giraffe']) as any; + + it('array branch: items enum + minItems 1', () => { + expect(schema.anyOf[0]).toMatchObject({ + type: 'array', + items: { enum: ['elephant', 'lion', 'giraffe'] }, + minItems: 1, + }); + }); + + it('object branch: propertyNames enum + minProperties 1', () => { + expect(schema.anyOf[1]).toMatchObject({ + type: 'object', + propertyNames: { enum: ['elephant', 'lion', 'giraffe'] }, + minProperties: 1, + }); + }); + + it('string branch: enum', () => { + expect(schema.anyOf[2]).toMatchObject({ + type: 'string', + enum: ['elephant', 'lion', 'giraffe'], + }); + }); + }); + + it('single string is normalized to array', () => { + const schema = buildIsContainedBySchema('elephant') as any; + expect(schema.anyOf[0]).toMatchObject({ items: { enum: ['elephant'] } }); + expect(schema.anyOf[2]).toMatchObject({ enum: ['elephant'] }); + }); +}); + +// --------------------------------------------------------------------------- +// buildIsNotContainedBySchema +// --------------------------------------------------------------------------- + +describe('buildIsNotContainedBySchema', () => { + const schema = buildIsNotContainedBySchema(['elephant', 'lion', 'giraffe']) as any; + + it('array branch: minItems + not(items enum)', () => { + const branch = schema.anyOf[0]; + expect(branch.allOf).toContainEqual({ minItems: 1, type: 'array' }); + expect(branch.allOf).toContainEqual({ + not: { items: { enum: ['elephant', 'lion', 'giraffe'] }, type: 'array' }, + }); + }); + + it('object branch: minProperties + not(propertyNames enum)', () => { + const branch = schema.anyOf[1]; + expect(branch.allOf).toContainEqual({ minProperties: 1, type: 'object' }); + expect(branch.allOf).toContainEqual({ + not: { propertyNames: { enum: ['elephant', 'lion', 'giraffe'] }, type: 'object' }, + }); + }); + + it('string branch: not enum', () => { + expect(schema.anyOf[2]).toMatchObject({ + not: { enum: ['elephant', 'lion', 'giraffe'] }, + type: 'string', + }); + }); + + it('single string is normalized to array', () => { + const single = buildIsNotContainedBySchema('elephant') as any; + expect(single.anyOf[2]).toMatchObject({ not: { enum: ['elephant'] } }); + }); +}); + +// --------------------------------------------------------------------------- +// getOperatorSchema +// --------------------------------------------------------------------------- + +describe('getOperatorSchema', () => { + const makeCondition = (operator: V2Condition['operator'], value?: V2Condition['value']): V2Condition => ({ + field: 'injured_animal', + id: 'condition-1', + operator, + value, + }); + + it('CONTAINS delegates to buildContainsSchema', () => { + const schema = getOperatorSchema(makeCondition('CONTAINS', 'elephant')) as any; + expect(schema.anyOf).toBeDefined(); + expect(schema.anyOf[2]).toMatchObject({ type: 'string', pattern: 'elephant' }); + }); + + it('CONTAINS with array value', () => { + const schema = getOperatorSchema(makeCondition('CONTAINS', ['elephant', 'lion'])) as any; + expect(schema.anyOf[0].allOf).toHaveLength(2); + }); + + it('CONTAINS throws when value is missing', () => { + expect(() => getOperatorSchema(makeCondition('CONTAINS'))).toThrow(); + }); + + it('HAS_INPUT delegates to buildHasInputSchema', () => { + const schema = getOperatorSchema(makeCondition('HAS_INPUT')) as any; + expect(schema.allOf).toBeDefined(); + expect(schema.allOf[0]).toEqual({ not: { type: 'null' } }); + }); + + it('IS_NOT_EMPTY produces the same schema as HAS_INPUT', () => { + const hasInput = getOperatorSchema(makeCondition('HAS_INPUT')); + const isNotEmpty = getOperatorSchema(makeCondition('IS_NOT_EMPTY')); + expect(hasInput).toEqual(isNotEmpty); + }); + + it('DOES_NOT_HAVE_INPUT delegates to buildDoesNotHaveInputSchema', () => { + const schema = getOperatorSchema(makeCondition('DOES_NOT_HAVE_INPUT')) as any; + expect(schema.not).toBeDefined(); + }); + + it('IS_EMPTY returns root-level anyOf with 5 branches', () => { + const schema = getOperatorSchema(makeCondition('IS_EMPTY')) as any; + expect(schema.anyOf).toHaveLength(5); + expect(schema.anyOf[0]).toEqual({ not: { required: ['injured_animal'] } }); + }); + + it('INPUT_IS_EXACTLY delegates to buildInputIsExactlySchema', () => { + const schema = getOperatorSchema(makeCondition('INPUT_IS_EXACTLY', 'elephant')); + expect(schema).toEqual({ const: 'elephant' }); + }); + + it('INPUT_IS_EXACTLY throws when value is undefined', () => { + expect(() => getOperatorSchema(makeCondition('INPUT_IS_EXACTLY'))).toThrow(); + }); + + it('IS_EXACTLY delegates to buildIsExactlySchema', () => { + const schema = getOperatorSchema(makeCondition('IS_EXACTLY', 'elephant')) as any; + expect(schema.anyOf).toHaveLength(5); + expect(schema.anyOf[4]).toEqual({ const: 'elephant', type: 'string' }); + }); + + it('IS_EXACTLY throws when value is missing', () => { + expect(() => getOperatorSchema(makeCondition('IS_EXACTLY'))).toThrow(); + }); + + it('IS_CONTAINED_BY delegates to buildIsContainedBySchema', () => { + const schema = getOperatorSchema(makeCondition('IS_CONTAINED_BY', ['elephant', 'lion'])) as any; + expect(schema.anyOf[0]).toMatchObject({ type: 'array', items: { enum: ['elephant', 'lion'] } }); + }); + + it('IS_CONTAINED_BY throws when value is missing', () => { + expect(() => getOperatorSchema(makeCondition('IS_CONTAINED_BY'))).toThrow(); + }); + + it('IS_NOT_CONTAINED_BY delegates to buildIsNotContainedBySchema', () => { + const schema = getOperatorSchema(makeCondition('IS_NOT_CONTAINED_BY', ['elephant'])) as any; + expect(schema.anyOf[2]).toMatchObject({ not: { enum: ['elephant'] } }); + }); + + it('IS_NOT_CONTAINED_BY throws when value is missing', () => { + expect(() => getOperatorSchema(makeCondition('IS_NOT_CONTAINED_BY'))).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// buildConditionSchema +// --------------------------------------------------------------------------- + +describe('buildConditionSchema', () => { + const makeCondition = (operator: V2Condition['operator'], value?: V2Condition['value']): V2Condition => ({ + field: 'injured_animal', + id: 'condition-1', + operator, + value, + }); + + it('IS_EMPTY returns root-level anyOf directly (no properties wrapper)', () => { + const schema = buildConditionSchema(makeCondition('IS_EMPTY')) as any; + expect(schema.anyOf).toBeDefined(); + expect(schema.properties).toBeUndefined(); + }); + + it('non-IS_EMPTY operators are wrapped in properties/required', () => { + const schema = buildConditionSchema(makeCondition('CONTAINS', 'elephant')) as any; + expect(schema.properties).toBeDefined(); + expect(schema.properties.injured_animal).toBeDefined(); + expect(schema.required).toContain('injured_animal'); + }); + + it('IS_NOT_EMPTY is wrapped in properties/required', () => { + const schema = buildConditionSchema(makeCondition('IS_NOT_EMPTY')) as any; + expect(schema.properties.injured_animal.allOf).toBeDefined(); + }); + + it('IS_EXACTLY is wrapped in properties/required', () => { + const schema = buildConditionSchema(makeCondition('IS_EXACTLY', 'elephant')) as any; + expect(schema.properties.injured_animal.anyOf).toHaveLength(5); + expect(schema.required).toContain('injured_animal'); + }); +}); + +// --------------------------------------------------------------------------- +// buildSchemaBasedCondition +// --------------------------------------------------------------------------- + +describe('buildSchemaBasedCondition', () => { + const makeCondition = ( + field: string, + operator: V2Condition['operator'], + value?: V2Condition['value'], + ): V2Condition => ({ field, id: `condition-${field}`, operator, value }); + + it('throws when conditions array is empty', () => { + expect(() => buildSchemaBasedCondition([])).toThrow(); + }); + + describe('single condition (non-IS_EMPTY)', () => { + it('uses field scope', () => { + const result = buildSchemaBasedCondition([makeCondition('injured_animal', 'CONTAINS', 'elephant')]); + expect(result.scope).toBe('#/properties/injured_animal'); + }); + + it('schema is the operator schema (not wrapped)', () => { + const result = buildSchemaBasedCondition([makeCondition('injured_animal', 'IS_NOT_EMPTY')]) as any; + expect(result.schema.allOf).toBeDefined(); // buildHasInputSchema output + }); + + it('IS_EXACTLY single field scope', () => { + const result = buildSchemaBasedCondition([makeCondition('status', 'IS_EXACTLY', 'active')]); + expect(result.scope).toBe('#/properties/status'); + expect((result.schema as any).anyOf).toHaveLength(5); + }); + }); + + describe('single IS_EMPTY condition', () => { + it('uses root scope', () => { + const result = buildSchemaBasedCondition([makeCondition('injured_animal', 'IS_EMPTY')]); + expect(result.scope).toBe('#'); + }); + + it('schema is the IS_EMPTY anyOf', () => { + const result = buildSchemaBasedCondition([makeCondition('injured_animal', 'IS_EMPTY')]) as any; + expect(result.schema.anyOf).toHaveLength(5); + expect(result.schema.anyOf[0]).toEqual({ not: { required: ['injured_animal'] } }); + }); + }); + + describe('multiple conditions', () => { + it('uses root scope with allOf', () => { + const result = buildSchemaBasedCondition([ + makeCondition('injured_animal', 'CONTAINS', 'elephant'), + makeCondition('requires_attention', 'IS_NOT_EMPTY'), + ]); + expect(result.scope).toBe('#'); + expect((result.schema as any).allOf).toHaveLength(2); + }); + + it('each non-IS_EMPTY condition is wrapped in properties/required', () => { + const result = buildSchemaBasedCondition([ + makeCondition('injured_animal', 'CONTAINS', 'elephant'), + makeCondition('requires_attention', 'IS_NOT_EMPTY'), + ]) as any; + expect(result.schema.allOf[0].properties.injured_animal).toBeDefined(); + expect(result.schema.allOf[0].required).toContain('injured_animal'); + }); + + it('IS_EMPTY in multi-condition contributes root-level anyOf directly', () => { + const result = buildSchemaBasedCondition([ + makeCondition('injured_animal', 'IS_EMPTY'), + makeCondition('requires_attention', 'IS_NOT_EMPTY'), + ]) as any; + // First entry is the IS_EMPTY root schema + expect(result.schema.allOf[0].anyOf).toBeDefined(); + expect(result.schema.allOf[0].anyOf[0]).toEqual({ not: { required: ['injured_animal'] } }); + // Second entry is field-wrapped IS_NOT_EMPTY + expect(result.schema.allOf[1].properties.requires_attention).toBeDefined(); + }); + + it('IS_CONTAINED_BY multi-value condition', () => { + const result = buildSchemaBasedCondition([ + makeCondition('animal_type', 'IS_CONTAINED_BY', ['elephant', 'lion']), + makeCondition('status', 'IS_EXACTLY', 'active'), + ]) as any; + expect(result.schema.allOf).toHaveLength(2); + expect(result.schema.allOf[0].properties.animal_type.anyOf[0]).toMatchObject({ + type: 'array', + items: { enum: ['elephant', 'lion'] }, + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// createSectionRule +// --------------------------------------------------------------------------- + +describe('createSectionRule', () => { + it('creates a SHOW rule', () => { + const rule = createSectionRule([ + { field: 'injured_animal', id: 'c1', operator: 'IS_NOT_EMPTY' }, + ]); + expect(rule.effect).toBe('SHOW'); + }); + + it('condition scope matches field for single non-IS_EMPTY', () => { + const rule = createSectionRule([ + { field: 'injured_animal', id: 'c1', operator: 'IS_NOT_EMPTY' }, + ]); + expect(rule.condition.scope).toBe('#/properties/injured_animal'); + }); + + it('condition scope is root for IS_EMPTY', () => { + const rule = createSectionRule([ + { field: 'injured_animal', id: 'c1', operator: 'IS_EMPTY' }, + ]); + expect(rule.condition.scope).toBe('#'); + }); +}); + +// --------------------------------------------------------------------------- +// validateConditions +// --------------------------------------------------------------------------- + +describe('validateConditions', () => { + const fields = ['injured_animal', 'requires_attention', 'status']; + + it('passes with valid CONTAINS condition', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'CONTAINS', value: 'elephant' }], + fields, + ), + ).not.toThrow(); + }); + + it('passes with IS_EMPTY (no value required)', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'IS_EMPTY', value: null }], + fields, + ), + ).not.toThrow(); + }); + + it('passes with IS_NOT_EMPTY', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'IS_NOT_EMPTY' }], + fields, + ), + ).not.toThrow(); + }); + + it('throws for unknown operator', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'FAKE_OP' as any }], + fields, + ), + ).toThrow(/Invalid operator/); + }); + + it('throws for unknown field', () => { + expect(() => + validateConditions( + [{ field: 'nonexistent_field', id: 'c1', operator: 'IS_NOT_EMPTY' }], + fields, + ), + ).toThrow(/Unknown field/); + }); + + it('throws when CONTAINS value is missing', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'CONTAINS' }], + fields, + ), + ).toThrow(/CONTAINS.*requires a value/); + }); + + it('throws when CONTAINS value is null', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'CONTAINS', value: null }], + fields, + ), + ).toThrow(/CONTAINS.*requires a value/); + }); + + it('throws when IS_EXACTLY value is missing', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'IS_EXACTLY' }], + fields, + ), + ).toThrow(/IS_EXACTLY.*requires a value/); + }); + + it('throws when IS_CONTAINED_BY value is missing', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'IS_CONTAINED_BY' }], + fields, + ), + ).toThrow(/IS_CONTAINED_BY.*requires a value/); + }); + + it('throws when IS_NOT_CONTAINED_BY value is missing', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'IS_NOT_CONTAINED_BY' }], + fields, + ), + ).toThrow(/IS_NOT_CONTAINED_BY.*requires a value/); + }); + + it('throws when INPUT_IS_EXACTLY value is undefined', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'INPUT_IS_EXACTLY' }], + fields, + ), + ).toThrow(/INPUT_IS_EXACTLY.*requires a value/); + }); + + it('INPUT_IS_EXACTLY allows null value', () => { + expect(() => + validateConditions( + [{ field: 'injured_animal', id: 'c1', operator: 'INPUT_IS_EXACTLY', value: null }], + fields, + ), + ).not.toThrow(); + }); + + it('validates multiple conditions together', () => { + expect(() => + validateConditions( + [ + { field: 'injured_animal', id: 'c1', operator: 'CONTAINS', value: 'elephant' }, + { field: 'requires_attention', id: 'c2', operator: 'IS_NOT_EMPTY' }, + { field: 'status', id: 'c3', operator: 'IS_CONTAINED_BY', value: ['open', 'active'] }, + ], + fields, + ), + ).not.toThrow(); + }); + + it('reports the first invalid condition in a list', () => { + expect(() => + validateConditions( + [ + { field: 'injured_animal', id: 'c1', operator: 'IS_NOT_EMPTY' }, + { field: 'injured_animal', id: 'c2', operator: 'CONTAINS' }, // missing value + ], + fields, + ), + ).toThrow(/CONTAINS.*requires a value/); + }); +});