diff --git a/src/test-helpers.ts b/src/test-helpers.ts new file mode 100644 index 0000000..e6f6930 --- /dev/null +++ b/src/test-helpers.ts @@ -0,0 +1,27 @@ +import { JSONSchema } from "@json-schema-tools/meta-schema"; + +export const testCalls = ( + mockMutation: any, + schema: JSONSchema, + isCycle: any = expect.any(Boolean), + nth?: number, + parent: any = expect.anything(), +) => { + if (parent === false) { parent = undefined; } + if (nth) { + expect(mockMutation).toHaveBeenNthCalledWith( + nth, + schema, + isCycle, + expect.any(String), + parent + ); + } else { + expect(mockMutation).toHaveBeenCalledWith( + schema, + isCycle, + expect.any(String), + parent + ); + } +}; diff --git a/src/index.test.ts b/src/traversal.basic.test.ts similarity index 50% rename from src/index.test.ts rename to src/traversal.basic.test.ts index 400002d..a79110e 100644 --- a/src/index.test.ts +++ b/src/traversal.basic.test.ts @@ -749,622 +749,4 @@ describe("traverse", () => { expect(mockMutation).toHaveBeenCalledTimes(4); }); }); - - describe("cycle detection", () => { - it("handles basic cycles", () => { - const schema = { type: "object", properties: { foo: {} } }; - schema.properties.foo = schema; - const mockMutation = jest.fn((s) => s); - traverse(schema as JSONSchema, mockMutation); - expect(mockMutation).toHaveBeenCalledTimes(1); - }); - - it("does not follow $refs", () => { - const schema = { type: "object", properties: { foo: { $ref: "#" } } } as JSONSchema; - const mockMutation = jest.fn((s) => s); - traverse(schema, mockMutation); - expect(mockMutation).toHaveBeenCalledTimes(2); - }); - - it("handles chained cycles", () => { - const schema = { - title: "1", - type: "object", - properties: { - foo: { - title: "2", - items: [ - { - title: "3", - type: "array", - items: { title: "4" }, - }, - ], - }, - }, - }; - schema.properties.foo.items[0].items = schema; - const mockMutation = jest.fn((s) => s); - traverse(schema as JSONSchema, mockMutation); - expect(mockMutation).toHaveBeenCalledTimes(3); - }); - - it("handles chained cycles where the cycle starts in the middle", () => { - const schema = { - title: "1", - type: "object", - properties: { - foo: { - title: "2", - anyOf: [ - { - title: "3", - type: "array", - items: { - title: "4", - properties: { - baz: { title: "5" }, - }, - }, - }, - ], - }, - }, - }; - schema.properties.foo.anyOf[0].items.properties.baz = schema.properties.foo; - const mockMutation = jest.fn((s) => s); - traverse(schema as JSONSchema, mockMutation); - expect(mockMutation).toHaveBeenCalledTimes(4); - }); - - it("handles chained cycles where the cycle starts in the middle of a different branch of the tree", () => { - const schema = { - title: "1", - type: "object", - properties: { - foo: { - title: "2", - anyOf: [ - { - title: "3", - type: "array", - items: { - title: "4", - properties: { - baz: { title: "5" }, - }, - }, - }, - ], - }, - bar: { - title: "6", - type: "object", - allOf: [ - { title: "7", type: "object", properties: { baz: { title: "8" } } }, - ], - }, - }, - }; - schema.properties.foo.anyOf[0].items.properties.baz = schema; - schema.properties.bar.allOf[0].properties.baz = schema.properties.foo.anyOf[0]; - const mockMutation = jest.fn((s) => s); - traverse(schema as JSONSchema, mockMutation); - expect(mockMutation).toHaveBeenCalledTimes(6); - }); - - it("handles multiple cycles", () => { - const schema: any = { - title: "1", - type: "object", - properties: { - foo: { - title: "2", - anyOf: [ - { - title: "3", - type: "array", - items: { - title: "4", - properties: { - baz: { title: "5" }, - }, - }, - }, - ], - }, - bar: { - title: "6", - type: "object", - allOf: [ - { title: "7", type: "object", properties: { baz: { title: "5" } } }, - ], - }, - }, - }; - schema.properties.bar.allOf[0].properties.baz = schema.properties.foo.anyOf[0].items.properties.baz; - schema.properties.bar.allOf.push(schema); // should not add any calls - schema.properties.bar.allOf.push(schema.properties.foo.anyOf[0].items); // should not add any calls - const mockMutation = jest.fn((s) => s); - traverse(schema, mockMutation); - expect(mockMutation).toHaveBeenCalledTimes(7); - }); - - it("returned mutated schema has circ refs back to the mutated schema instead of original", () => { - const schema: any = { - title: "2", - type: "object", - properties: { - foo: { - $ref: "#" - }, - }, - }; - const result = traverse(schema, (s: JSONSchema) => { - if ((s as JSONSchemaObject).$ref) { return schema; } - return s; - }, { mutable: true }) as JSONSchemaObject; - - const rProps = result.properties as Properties; - expect(rProps.foo).toBe(result); - }); - - it("handles the mutation function adding a cycle", () => { - const schema = { - title: "1", - type: "object", - properties: { - foo: { - title: "2", - anyOf: [ - { - title: "3", - type: "array", - items: { - title: "4", - properties: { - baz: { title: "5" }, - }, - }, - }, - ], - }, - }, - }; - schema.properties.foo.anyOf[0].items.properties.baz = schema.properties.foo; - const mockMutation = jest.fn((s) => s); - traverse(schema as JSONSchema, mockMutation); - expect(mockMutation).toHaveBeenCalledTimes(4); - }); - - }); - - describe("skipFirstMutation", () => { - it("skips the first schema when the option skipFirstMutation is true", () => { - const testSchema: any = { anyOf: [{}, {}] }; - const mockMutation = jest.fn((mockS) => mockS); - - traverse(testSchema, mockMutation, { skipFirstMutation: true }); - - testCalls(mockMutation, testSchema.anyOf[0]); - testCalls(mockMutation, testSchema.anyOf[1]); - expect(mockMutation).toHaveBeenCalledTimes(2); - }); - - it("skips first mutation when schema is a bool", () => { - const testSchema: any = true; - const mockMutation = jest.fn((mockS) => mockS); - - traverse(testSchema, mockMutation, { skipFirstMutation: true }); - - expect(mockMutation).not.toHaveBeenCalledWith(testSchema, expect.any, expect.any); - expect(mockMutation).toHaveBeenCalledTimes(0); - }); - - it("When the 2nd schema down is a cycle to its parent, the mutation function is called regardless", () => { - const testSchema1: any = { - title: "skipFirstCycles", - type: "object", - properties: { - skipFirstCycle: {} - } - }; - - const testSchema2: any = { - title: "skipFirstCycles", - type: "object", - items: {} - }; - - testSchema1.properties.skipFirstCycle = testSchema1; - testSchema2.items = testSchema2; - - const mockMutation1 = jest.fn((mockS) => mockS); - traverse(testSchema1, mockMutation1, { skipFirstMutation: true, mutable: true }) as JSONSchemaObject; - - const mockMutation2 = jest.fn((mockS) => mockS); - traverse(testSchema2, mockMutation2, { skipFirstMutation: true, mutable: true }) as JSONSchemaObject; - - - testCalls(mockMutation1, testSchema1); - expect(mockMutation1).toHaveBeenCalledTimes(1); - expect((testSchema1.properties as Properties).skipFirstCycle).toBe(testSchema1); - - testCalls(mockMutation2, testSchema2); - expect(mockMutation2).toHaveBeenCalledTimes(1); - expect(testSchema2.items).toBe(testSchema2); - - expect(mockMutation1).toHaveBeenCalledTimes(1); - expect(mockMutation1).toHaveBeenCalledWith( - testSchema1.properties.skipFirstCycle, - true, - expect.any(String), - testSchema1 - ); - - expect(mockMutation2).toHaveBeenCalledTimes(1); - expect(mockMutation2).toHaveBeenCalledWith( - testSchema2.items, - true, - expect.any(String), - testSchema2 - ); - }); - - }); - - describe("isCycle", () => { - - it("true when the schema is a deep cycle", () => { - const testSchema = { - type: "object", - properties: { - foo: { - type: "object", - properties: { bar: {} }, - }, - }, - } as any; - - testSchema.properties.foo.properties.bar = testSchema.properties.foo; - - const mockMutation = jest.fn((mockS) => mockS); - - traverse(testSchema as JSONSchema, mockMutation, { mutable: true }); - - testCalls(mockMutation, testSchema.properties.foo, true); - expect(mockMutation).not.toHaveBeenCalledWith( - testSchema.properties.foo, - false, - "$.properties.foo", - expect.anything() - ); - expect(mockMutation).toHaveBeenCalledTimes(2); - }); - - it("true when the schema is the root of a cycle", () => { - const testSchema = { - type: "object", - properties: { - foo: {} - } - }; - testSchema.properties.foo = testSchema; - - const mockMutation = jest.fn((mockS) => mockS); - - traverse(testSchema as JSONSchema, mockMutation, { mutable: true }); - - expect(mockMutation).toHaveBeenCalledWith( - testSchema, - true, - "$", - undefined - ); - }); - - it("true when the cycle is inside oneOf", () => { - const testSchema = { - title: "a", - oneOf: [{ - title: "b", - type: "object", - properties: { - a: {} - } - }] - } as any; - testSchema.oneOf[0].properties.a = testSchema; - - const mockMutation = jest.fn((mockS) => mockS); - - traverse(testSchema as JSONSchema, mockMutation, { mutable: false }); - - expect(mockMutation).nthCalledWith( - 1, - testSchema.oneOf[0], - false, - expect.any(String), - testSchema - ); - expect(mockMutation).nthCalledWith( - 2, - testSchema, - true, - "$", - undefined - ); - }); - }); - - describe("bfs", () => { - it("call order is correct for nested objects and arrays", () => { - const testSchema = { - type: "object", - properties: { - foo: { - type: "array", - items: [ - { type: "string" }, - { type: "number" }, - ] - } - } - } as any; - const mockMutation = jest.fn((mockS) => mockS); - - traverse(testSchema as JSONSchema, mockMutation, { bfs: true, }); - - testCalls(mockMutation, testSchema, false, 1, false); - testCalls(mockMutation, testSchema.properties.foo, false, 2); - testCalls(mockMutation, testSchema.properties.foo.items[0], false, 3); - testCalls(mockMutation, testSchema.properties.foo.items[1], false, 4); - }); - - it("works with mutable settings", () => { - const testSchema = { - type: "object", - properties: { - foo: { - type: "array", - items: [ - { type: "string" }, - { type: "number" }, - ] - } - } - } as any; - const mockMutation = jest.fn((mockS) => mockS); - - traverse(testSchema as JSONSchema, mockMutation, { bfs: true, mutable: true }); - - testCalls(mockMutation, testSchema, false, 1, false); - testCalls(mockMutation, testSchema.properties.foo, false, 2); - testCalls(mockMutation, testSchema.properties.foo.items[0], false, 3); - testCalls(mockMutation, testSchema.properties.foo.items[1], false, 4); - }); - - it("handles basic cycles when bfs is true", () => { - const schema = { type: "object", properties: { foo: {} } } as any; - schema.properties.foo = schema; - const mockMutation = jest.fn((s) => s); - - traverse(schema as JSONSchema, mockMutation, { bfs: true }); - - expect(mockMutation).toHaveBeenCalledTimes(1); - }); - - it("handles chained cycles when bfs is true", () => { - const schema = { - title: "1", - type: "object", - properties: { - foo: { - title: "2", - items: [ - { - title: "3", - type: "array", - items: { title: "4" }, - }, - ], - }, - }, - } as any; - schema.properties.foo.items[0].items = schema; - const mockMutation = jest.fn((s) => s); - - traverse(schema as JSONSchema, mockMutation, { bfs: true }); - - expect(mockMutation).toHaveBeenCalledTimes(3); - }); - - it("bfs still calls mutation for root cycles when skipFirstMutation is true", () => { - const schema: any = { title: "a", items: {} }; - schema.items = schema; - const mockMutation = jest.fn((s) => s); - - traverse(schema as JSONSchema, mockMutation, { bfs: true, skipFirstMutation: true, mutable: true }); - - expect(mockMutation).toHaveBeenCalledTimes(1); - expect(mockMutation).toHaveBeenCalledWith( - schema, - true, - expect.any(String), - schema - ); - }); - }); -describe("Mutability settings", () => { - it("defaults to being immutable", () => { - const s = { - type: "object", - properties: { - foo: { type: "string" }, - bar: { type: "number" } - } - } as JSONSchema; - - const frozenS = Object.freeze(s); - - const result = traverse(frozenS, () => { - return { hello: "world" }; - }); - - expect(frozenS).not.toBe(result); - expect(frozenS).not.toBe(result); - }); - - describe("mutable: false", () => { - it("cycles are preserved, but reference is not the same as original", () => { - const s = { - type: "object", - properties: { - foo: {}, - } - }; - s.properties.foo = s; - - const frozenS = Object.freeze(s); - - const result = traverse(frozenS as JSONSchema, (ss) => ss, { mutable: false }) as JSONSchemaObject; - - expect(frozenS).not.toBe(result); - expect((result.properties as Properties).foo).toBe(result); - expect(frozenS.properties.foo).not.toBe(result); - expect(frozenS.properties.foo).toEqual(result); - expect(frozenS.properties.foo).toEqual(frozenS); - }); - - it("a copy of the first schema is given even when skipFirstMutation is used", () => { - const s = { - type: "object", - properties: { - foo: { type: "string" }, - } - }; - - const frozenS = Object.freeze(s); - - const result = traverse(frozenS as JSONSchema, (ss) => ss, { mutable: false, skipFirstMutation: true }) as JSONSchemaObject; - - expect(frozenS).not.toBe(result); - expect((result.properties as Properties).foo).not.toBe(frozenS.properties.foo); - expect((result.properties as Properties).foo).toEqual(frozenS.properties.foo); - }); - - it("returns a deep copy when bfs is used (IE bfs doesn't change the behavior)", () => { - const s = { - type: "object", - properties: { - foo: { - type: "array", - items: [ - { type: "string" }, - { type: "number" } - ] - }, - } - }; - - const frozenS = Object.freeze(s); - - const result = traverse(frozenS as JSONSchema, (ss) => { - if (ss === true || ss === false) { return ss; } - return { hello: "world", ...ss }; - }, { mutable: false, bfs: true }) as JSONSchemaObject; - - expect(frozenS).not.toBe(result); - expect(result.hello).toBe("world"); - expect((result.properties as Properties).foo).not.toBe(frozenS.properties.foo); - expect((result.properties as Properties).foo.items[0]).not.toBe(frozenS.properties.foo.items[0]); - - expect((result.properties as Properties).foo.hello).toBe("world"); - expect((result.properties as Properties).foo.items[0].hello).toBe("world"); - expect((result.properties as Properties).foo.items[1].hello).toBe("world"); - }); - - it("skipFirstMutation and bfs combined also has no effect on mutability", () => { - const s = { - type: "object", - properties: { - foo: { - type: "array", - items: [ - { type: "string" }, - { type: "number" } - ] - }, - } - }; - - const frozenS = Object.freeze(s); - - const result = traverse(frozenS as JSONSchema, (ss) => { - if (ss === true || ss === false) { return ss; } - return { hello: "world", ...ss }; - }, { mutable: false, bfs: true, skipFirstMutation: true }) as JSONSchemaObject; - - expect(frozenS).not.toBe(result); - expect(result.hello).not.toBeDefined(); - expect((result.properties as Properties).foo).not.toBe(frozenS.properties.foo); - expect((result.properties as Properties).foo.items[0]).not.toBe(frozenS.properties.foo.items[0]); - - expect((result.properties as Properties).foo.hello).toBe("world"); - expect((result.properties as Properties).foo.items[0].hello).toBe("world"); - expect((result.properties as Properties).foo.items[1].hello).toBe("world"); - }); - }); - - describe("mutable: true", () => { - it("cycles are preserved, reference is the same as original", () => { - const s = { - type: "object", - properties: { - foo: {}, - } - }; - s.properties.foo = s; - - - const result = traverse(s as JSONSchema, (ss) => ss, { mutable: true }) as JSONSchemaObject; - - expect(s).toBe(result); - expect((result.properties as Properties).foo).toBe(result); - expect(s.properties.foo).toBe(s); - expect((result.properties as Properties).foo).toBe(s); - }); - - it("the first schema is returned unmutated when skipFirstMutation is used", () => { - const s = { - type: "object", - properties: { - foo: { type: "string" }, - } - }; - - const result = traverse(s as JSONSchema, (ss: any) => { ss.hello = "world"; return ss; }, { mutable: true, skipFirstMutation: true }) as JSONSchemaObject; - - expect(s).toBe(result); - expect((s as any).hello).not.toBeDefined(); - expect((s.properties.foo as any).hello).toBe("world") - }); - - it("bfs also preserves refs", () => { - const s = { - type: "object", - properties: { - foo: { type: "string" }, - } - }; - - const result = traverse(s as JSONSchema, (ss: any) => { ss.hello = "world"; return ss; }, { mutable: true, bfs: true }) as JSONSchemaObject; - - expect(s).toBe(result); - expect((s as any).hello).toBe("world"); - expect((s.properties.foo as any).hello).toBe("world") - expect((result.properties as Properties).foo).toBe(s.properties.foo); - }); - }); -}); }); diff --git a/src/traversal.cycles.test.ts b/src/traversal.cycles.test.ts new file mode 100644 index 0000000..53676f1 --- /dev/null +++ b/src/traversal.cycles.test.ts @@ -0,0 +1,278 @@ +import traverse from "./"; +import { Properties, JSONSchemaObject, JSONSchema } from "@json-schema-tools/meta-schema"; +import { testCalls } from "./test-helpers"; + +describe("traverse", () => { + + describe("cycle detection", () => { + it("handles basic cycles", () => { + const schema = { type: "object", properties: { foo: {} } }; + schema.properties.foo = schema; + const mockMutation = jest.fn((s) => s); + traverse(schema as JSONSchema, mockMutation); + expect(mockMutation).toHaveBeenCalledTimes(1); + }); + + it("does not follow $refs", () => { + const schema = { type: "object", properties: { foo: { $ref: "#" } } } as JSONSchema; + const mockMutation = jest.fn((s) => s); + traverse(schema, mockMutation); + expect(mockMutation).toHaveBeenCalledTimes(2); + }); + + it("handles chained cycles", () => { + const schema = { + title: "1", + type: "object", + properties: { + foo: { + title: "2", + items: [ + { + title: "3", + type: "array", + items: { title: "4" }, + }, + ], + }, + }, + }; + schema.properties.foo.items[0].items = schema; + const mockMutation = jest.fn((s) => s); + traverse(schema as JSONSchema, mockMutation); + expect(mockMutation).toHaveBeenCalledTimes(3); + }); + + it("handles chained cycles where the cycle starts in the middle", () => { + const schema = { + title: "1", + type: "object", + properties: { + foo: { + title: "2", + anyOf: [ + { + title: "3", + type: "array", + items: { + title: "4", + properties: { + baz: { title: "5" }, + }, + }, + }, + ], + }, + }, + }; + schema.properties.foo.anyOf[0].items.properties.baz = schema.properties.foo; + const mockMutation = jest.fn((s) => s); + traverse(schema as JSONSchema, mockMutation); + expect(mockMutation).toHaveBeenCalledTimes(4); + }); + + it("handles chained cycles where the cycle starts in the middle of a different branch of the tree", () => { + const schema = { + title: "1", + type: "object", + properties: { + foo: { + title: "2", + anyOf: [ + { + title: "3", + type: "array", + items: { + title: "4", + properties: { + baz: { title: "5" }, + }, + }, + }, + ], + }, + bar: { + title: "6", + type: "object", + allOf: [ + { title: "7", type: "object", properties: { baz: { title: "8" } } }, + ], + }, + }, + }; + schema.properties.foo.anyOf[0].items.properties.baz = schema; + schema.properties.bar.allOf[0].properties.baz = schema.properties.foo.anyOf[0]; + const mockMutation = jest.fn((s) => s); + traverse(schema as JSONSchema, mockMutation); + expect(mockMutation).toHaveBeenCalledTimes(6); + }); + + it("handles multiple cycles", () => { + const schema: any = { + title: "1", + type: "object", + properties: { + foo: { + title: "2", + anyOf: [ + { + title: "3", + type: "array", + items: { + title: "4", + properties: { + baz: { title: "5" }, + }, + }, + }, + ], + }, + bar: { + title: "6", + type: "object", + allOf: [ + { title: "7", type: "object", properties: { baz: { title: "5" } } }, + ], + }, + }, + }; + schema.properties.bar.allOf[0].properties.baz = schema.properties.foo.anyOf[0].items.properties.baz; + schema.properties.bar.allOf.push(schema); // should not add any calls + schema.properties.bar.allOf.push(schema.properties.foo.anyOf[0].items); // should not add any calls + const mockMutation = jest.fn((s) => s); + traverse(schema, mockMutation); + expect(mockMutation).toHaveBeenCalledTimes(7); + }); + + it("returned mutated schema has circ refs back to the mutated schema instead of original", () => { + const schema: any = { + title: "2", + type: "object", + properties: { + foo: { + $ref: "#" + }, + }, + }; + const result = traverse(schema, (s: JSONSchema) => { + if ((s as JSONSchemaObject).$ref) { return schema; } + return s; + }, { mutable: true }) as JSONSchemaObject; + + const rProps = result.properties as Properties; + expect(rProps.foo).toBe(result); + }); + + it("handles the mutation function adding a cycle", () => { + const schema = { + title: "1", + type: "object", + properties: { + foo: { + title: "2", + anyOf: [ + { + title: "3", + type: "array", + items: { + title: "4", + properties: { + baz: { title: "5" }, + }, + }, + }, + ], + }, + }, + }; + schema.properties.foo.anyOf[0].items.properties.baz = schema.properties.foo; + const mockMutation = jest.fn((s) => s); + traverse(schema as JSONSchema, mockMutation); + expect(mockMutation).toHaveBeenCalledTimes(4); + }); + + describe("isCycle", () => { + + it("true when the schema is a deep cycle", () => { + const testSchema = { + type: "object", + properties: { + foo: { + type: "object", + properties: { bar: {} }, + }, + }, + } as any; + + testSchema.properties.foo.properties.bar = testSchema.properties.foo; + + const mockMutation = jest.fn((mockS) => mockS); + + traverse(testSchema as JSONSchema, mockMutation, { mutable: true }); + + testCalls(mockMutation, testSchema.properties.foo, true); + expect(mockMutation).not.toHaveBeenCalledWith( + testSchema.properties.foo, + false, + "$.properties.foo", + expect.anything() + ); + expect(mockMutation).toHaveBeenCalledTimes(2); + }); + + it("true when the schema is the root of a cycle", () => { + const testSchema = { + type: "object", + properties: { + foo: {} + } + }; + testSchema.properties.foo = testSchema; + + const mockMutation = jest.fn((mockS) => mockS); + + traverse(testSchema as JSONSchema, mockMutation, { mutable: true }); + + expect(mockMutation).toHaveBeenCalledWith( + testSchema, + true, + "$", + undefined + ); + }); + + it("true when the cycle is inside oneOf", () => { + const testSchema = { + title: "a", + oneOf: [{ + title: "b", + type: "object", + properties: { + a: {} + } + }] + } as any; + testSchema.oneOf[0].properties.a = testSchema; + + const mockMutation = jest.fn((mockS) => mockS); + + traverse(testSchema as JSONSchema, mockMutation, { mutable: false }); + + expect(mockMutation).nthCalledWith( + 1, + testSchema.oneOf[0], + false, + expect.any(String), + testSchema + ); + expect(mockMutation).nthCalledWith( + 2, + testSchema, + true, + "$", + undefined + ); + }); + }); +}); +}); diff --git a/src/traversal.options.test.ts b/src/traversal.options.test.ts new file mode 100644 index 0000000..399e5e0 --- /dev/null +++ b/src/traversal.options.test.ts @@ -0,0 +1,349 @@ +import traverse from "./"; +import { Properties, JSONSchemaObject, JSONSchema } from "@json-schema-tools/meta-schema"; +import { testCalls } from "./test-helpers"; + +describe("traverse", () => { + describe("skipFirstMutation", () => { + it("skips the first schema when the option skipFirstMutation is true", () => { + const testSchema: any = { anyOf: [{}, {}] }; + const mockMutation = jest.fn((mockS) => mockS); + + traverse(testSchema, mockMutation, { skipFirstMutation: true }); + + testCalls(mockMutation, testSchema.anyOf[0]); + testCalls(mockMutation, testSchema.anyOf[1]); + expect(mockMutation).toHaveBeenCalledTimes(2); + }); + + it("skips first mutation when schema is a bool", () => { + const testSchema: any = true; + const mockMutation = jest.fn((mockS) => mockS); + + traverse(testSchema, mockMutation, { skipFirstMutation: true }); + + expect(mockMutation).not.toHaveBeenCalledWith(testSchema, expect.any, expect.any); + expect(mockMutation).toHaveBeenCalledTimes(0); + }); + + it("When the 2nd schema down is a cycle to its parent, the mutation function is called regardless", () => { + const testSchema1: any = { + title: "skipFirstCycles", + type: "object", + properties: { + skipFirstCycle: {} + } + }; + + const testSchema2: any = { + title: "skipFirstCycles", + type: "object", + items: {} + }; + + testSchema1.properties.skipFirstCycle = testSchema1; + testSchema2.items = testSchema2; + + const mockMutation1 = jest.fn((mockS) => mockS); + traverse(testSchema1, mockMutation1, { skipFirstMutation: true, mutable: true }) as JSONSchemaObject; + + const mockMutation2 = jest.fn((mockS) => mockS); + traverse(testSchema2, mockMutation2, { skipFirstMutation: true, mutable: true }) as JSONSchemaObject; + + + testCalls(mockMutation1, testSchema1); + expect(mockMutation1).toHaveBeenCalledTimes(1); + expect((testSchema1.properties as Properties).skipFirstCycle).toBe(testSchema1); + + testCalls(mockMutation2, testSchema2); + expect(mockMutation2).toHaveBeenCalledTimes(1); + expect(testSchema2.items).toBe(testSchema2); + + expect(mockMutation1).toHaveBeenCalledTimes(1); + expect(mockMutation1).toHaveBeenCalledWith( + testSchema1.properties.skipFirstCycle, + true, + expect.any(String), + testSchema1 + ); + + expect(mockMutation2).toHaveBeenCalledTimes(1); + expect(mockMutation2).toHaveBeenCalledWith( + testSchema2.items, + true, + expect.any(String), + testSchema2 + ); + }); + + }); + describe("bfs", () => { + it("call order is correct for nested objects and arrays", () => { + const testSchema = { + type: "object", + properties: { + foo: { + type: "array", + items: [ + { type: "string" }, + { type: "number" }, + ] + } + } + } as any; + const mockMutation = jest.fn((mockS) => mockS); + + traverse(testSchema as JSONSchema, mockMutation, { bfs: true, }); + + testCalls(mockMutation, testSchema, false, 1, false); + testCalls(mockMutation, testSchema.properties.foo, false, 2); + testCalls(mockMutation, testSchema.properties.foo.items[0], false, 3); + testCalls(mockMutation, testSchema.properties.foo.items[1], false, 4); + }); + + it("works with mutable settings", () => { + const testSchema = { + type: "object", + properties: { + foo: { + type: "array", + items: [ + { type: "string" }, + { type: "number" }, + ] + } + } + } as any; + const mockMutation = jest.fn((mockS) => mockS); + + traverse(testSchema as JSONSchema, mockMutation, { bfs: true, mutable: true }); + + testCalls(mockMutation, testSchema, false, 1, false); + testCalls(mockMutation, testSchema.properties.foo, false, 2); + testCalls(mockMutation, testSchema.properties.foo.items[0], false, 3); + testCalls(mockMutation, testSchema.properties.foo.items[1], false, 4); + }); + + it("handles basic cycles when bfs is true", () => { + const schema = { type: "object", properties: { foo: {} } } as any; + schema.properties.foo = schema; + const mockMutation = jest.fn((s) => s); + + traverse(schema as JSONSchema, mockMutation, { bfs: true }); + + expect(mockMutation).toHaveBeenCalledTimes(1); + }); + + it("handles chained cycles when bfs is true", () => { + const schema = { + title: "1", + type: "object", + properties: { + foo: { + title: "2", + items: [ + { + title: "3", + type: "array", + items: { title: "4" }, + }, + ], + }, + }, + } as any; + schema.properties.foo.items[0].items = schema; + const mockMutation = jest.fn((s) => s); + + traverse(schema as JSONSchema, mockMutation, { bfs: true }); + + expect(mockMutation).toHaveBeenCalledTimes(3); + }); + + it("bfs still calls mutation for root cycles when skipFirstMutation is true", () => { + const schema: any = { title: "a", items: {} }; + schema.items = schema; + const mockMutation = jest.fn((s) => s); + + traverse(schema as JSONSchema, mockMutation, { bfs: true, skipFirstMutation: true, mutable: true }); + + expect(mockMutation).toHaveBeenCalledTimes(1); + expect(mockMutation).toHaveBeenCalledWith( + schema, + true, + expect.any(String), + schema + ); + }); + }); +describe("Mutability settings", () => { + it("defaults to being immutable", () => { + const s = { + type: "object", + properties: { + foo: { type: "string" }, + bar: { type: "number" } + } + } as JSONSchema; + + const frozenS = Object.freeze(s); + + const result = traverse(frozenS, () => { + return { hello: "world" }; + }); + + expect(frozenS).not.toBe(result); + expect(frozenS).not.toBe(result); + }); + + describe("mutable: false", () => { + it("cycles are preserved, but reference is not the same as original", () => { + const s = { + type: "object", + properties: { + foo: {}, + } + }; + s.properties.foo = s; + + const frozenS = Object.freeze(s); + + const result = traverse(frozenS as JSONSchema, (ss) => ss, { mutable: false }) as JSONSchemaObject; + + expect(frozenS).not.toBe(result); + expect((result.properties as Properties).foo).toBe(result); + expect(frozenS.properties.foo).not.toBe(result); + expect(frozenS.properties.foo).toEqual(result); + expect(frozenS.properties.foo).toEqual(frozenS); + }); + + it("a copy of the first schema is given even when skipFirstMutation is used", () => { + const s = { + type: "object", + properties: { + foo: { type: "string" }, + } + }; + + const frozenS = Object.freeze(s); + + const result = traverse(frozenS as JSONSchema, (ss) => ss, { mutable: false, skipFirstMutation: true }) as JSONSchemaObject; + + expect(frozenS).not.toBe(result); + expect((result.properties as Properties).foo).not.toBe(frozenS.properties.foo); + expect((result.properties as Properties).foo).toEqual(frozenS.properties.foo); + }); + + it("returns a deep copy when bfs is used (IE bfs doesn't change the behavior)", () => { + const s = { + type: "object", + properties: { + foo: { + type: "array", + items: [ + { type: "string" }, + { type: "number" } + ] + }, + } + }; + + const frozenS = Object.freeze(s); + + const result = traverse(frozenS as JSONSchema, (ss) => { + if (ss === true || ss === false) { return ss; } + return { hello: "world", ...ss }; + }, { mutable: false, bfs: true }) as JSONSchemaObject; + + expect(frozenS).not.toBe(result); + expect(result.hello).toBe("world"); + expect((result.properties as Properties).foo).not.toBe(frozenS.properties.foo); + expect((result.properties as Properties).foo.items[0]).not.toBe(frozenS.properties.foo.items[0]); + + expect((result.properties as Properties).foo.hello).toBe("world"); + expect((result.properties as Properties).foo.items[0].hello).toBe("world"); + expect((result.properties as Properties).foo.items[1].hello).toBe("world"); + }); + + it("skipFirstMutation and bfs combined also has no effect on mutability", () => { + const s = { + type: "object", + properties: { + foo: { + type: "array", + items: [ + { type: "string" }, + { type: "number" } + ] + }, + } + }; + + const frozenS = Object.freeze(s); + + const result = traverse(frozenS as JSONSchema, (ss) => { + if (ss === true || ss === false) { return ss; } + return { hello: "world", ...ss }; + }, { mutable: false, bfs: true, skipFirstMutation: true }) as JSONSchemaObject; + + expect(frozenS).not.toBe(result); + expect(result.hello).not.toBeDefined(); + expect((result.properties as Properties).foo).not.toBe(frozenS.properties.foo); + expect((result.properties as Properties).foo.items[0]).not.toBe(frozenS.properties.foo.items[0]); + + expect((result.properties as Properties).foo.hello).toBe("world"); + expect((result.properties as Properties).foo.items[0].hello).toBe("world"); + expect((result.properties as Properties).foo.items[1].hello).toBe("world"); + }); + }); + + describe("mutable: true", () => { + it("cycles are preserved, reference is the same as original", () => { + const s = { + type: "object", + properties: { + foo: {}, + } + }; + s.properties.foo = s; + + + const result = traverse(s as JSONSchema, (ss) => ss, { mutable: true }) as JSONSchemaObject; + + expect(s).toBe(result); + expect((result.properties as Properties).foo).toBe(result); + expect(s.properties.foo).toBe(s); + expect((result.properties as Properties).foo).toBe(s); + }); + + it("the first schema is returned unmutated when skipFirstMutation is used", () => { + const s = { + type: "object", + properties: { + foo: { type: "string" }, + } + }; + + const result = traverse(s as JSONSchema, (ss: any) => { ss.hello = "world"; return ss; }, { mutable: true, skipFirstMutation: true }) as JSONSchemaObject; + + expect(s).toBe(result); + expect((s as any).hello).not.toBeDefined(); + expect((s.properties.foo as any).hello).toBe("world") + }); + + it("bfs also preserves refs", () => { + const s = { + type: "object", + properties: { + foo: { type: "string" }, + } + }; + + const result = traverse(s as JSONSchema, (ss: any) => { ss.hello = "world"; return ss; }, { mutable: true, bfs: true }) as JSONSchemaObject; + + expect(s).toBe(result); + expect((s as any).hello).toBe("world"); + expect((s.properties.foo as any).hello).toBe("world") + expect((result.properties as Properties).foo).toBe(s.properties.foo); + }); + }); +}); +});