UNPKG

@player-ui/player

Version:

638 lines (551 loc) 16.9 kB
import { describe, it, expect, vitest } from "vitest"; import { replaceAt, set, omit } from "timm"; import { BindingParser } from "../../../binding"; import { ExpressionEvaluator } from "../../../expressions"; import { LocalModel, withParser } from "../../../data"; import { SchemaController } from "../../../schema"; import type { Logger } from "../../../logger"; import { TapableLogger } from "../../../logger"; import { Resolver } from ".."; import type { Node } from "../../parser"; import { NodeType, Parser } from "../../parser"; import { StringResolverPlugin, MultiNodePlugin, AssetPlugin, } from "../../plugins"; describe("Dynamic AST Transforms", () => { const content = { id: "main-view", type: "questionAnswer", title: [ { asset: { id: "title", type: "text", value: "Cool Page", }, }, ], primaryInfo: [ { asset: { id: "subtitle", type: "text", value: "{{year}}", }, }, ], }; it("Dynamically added Nodes are properly resolved/cached on rerender", () => { const model = new LocalModel({ year: "2021", }); const parser = new Parser(); new MultiNodePlugin().applyParser(parser); const bindingParser = new BindingParser(); const inputBinding = bindingParser.parse("year"); const rootNode = parser.parseObject(content); const resolver = new Resolver(rootNode!, { model, parseBinding: bindingParser.parse.bind(bindingParser), parseNode: parser.parseObject.bind(parser), evaluator: new ExpressionEvaluator({ model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), }); // basic transform to change the asset resolver.hooks.beforeResolve.tap("test-plugin", (node) => { if ( node?.type === NodeType.Asset || node?.type === NodeType.View || node?.type === NodeType.Value ) { let newNode = node; newNode.children?.forEach((child, i) => { if (child.path.length === 1) { // We have a child for this key // Check if it's an array and shouldn't be const { value: childNode } = child; if (childNode.type === "multi-node") { if (childNode.values.length === 1) { // If there's only 1 node, no need for a collection, just up-level the asset that's there const firstChild = childNode.values[0]; newNode = set( newNode, "children", replaceAt(newNode.children ?? [], i, { path: child.path, value: { ...firstChild, }, }), ); } } } }); if (newNode !== node) { // We updated something, set the children of the newNode to have the correct parent newNode.children?.forEach((child) => { // Don't worry about mutating here any new children are ones we created above child.value.parent = newNode; }); } return newNode; } return node; }); new StringResolverPlugin().applyResolver(resolver); const firstUpdate = resolver.update(); expect(firstUpdate).toStrictEqual({ id: "main-view", type: "questionAnswer", title: { asset: { id: "title", type: "text", value: "Cool Page", }, }, primaryInfo: { asset: { id: "subtitle", type: "text", value: "2021", }, }, }); model.set([[inputBinding, "2022"]]); const secondUpdate = resolver.update(new Set([inputBinding])); expect(secondUpdate).toStrictEqual({ id: "main-view", type: "questionAnswer", title: { asset: { id: "title", type: "text", value: "Cool Page", }, }, primaryInfo: { asset: { id: "subtitle", type: "text", value: "2022", }, }, }); }); it("Nodes are properly cached on rerender", () => { const model = new LocalModel({ year: "2021", }); const parser = new Parser(); const bindingParser = new BindingParser(); const inputBinding = bindingParser.parse("year"); const rootNode = parser.parseObject(content); const resolver = new Resolver(rootNode!, { model, parseBinding: bindingParser.parse.bind(bindingParser), parseNode: parser.parseObject.bind(parser), evaluator: new ExpressionEvaluator({ model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), }); resolver.update(); const resolveCache = resolver.getResolveCache(); resolver.update(new Set([inputBinding])); const newResolveCache = resolver.getResolveCache(); expect(resolveCache.size).toBe(newResolveCache.size); // The cached items between each re-render should stay the same for (const [k, v] of resolveCache) { const excludingUpdated = omit(v, "updated"); expect(newResolveCache.has(k)).toBe(true); expect(newResolveCache.get(k)).toMatchObject(excludingUpdated); } }); it("Cached node points to the correct parent node", () => { const view = { id: "main-view", type: "questionAnswer", title: [ { asset: { id: "title", type: "text", value: "Cool Page", }, }, ], primaryInfo: [ { asset: { id: "input", type: "input", value: "{{year}}", label: { asset: { id: "label", type: "text", value: "label", }, }, }, }, ], }; const model = new LocalModel({ year: "2021", }); const parser = new Parser(); new AssetPlugin().applyParser(parser); const bindingParser = new BindingParser(); const inputBinding = bindingParser.parse("year"); const rootNode = parser.parseObject(view); const resolver = new Resolver(rootNode!, { model, parseBinding: bindingParser.parse.bind(bindingParser), parseNode: parser.parseObject.bind(parser), evaluator: new ExpressionEvaluator({ model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), }); let inputNode: Node.Node | undefined; let labelNode: Node.Node | undefined; resolver.hooks.beforeResolve.tap("test", (node, options) => { if (node?.type === "asset" && node.value.id === "input") { // Add to dependencies options.data.model.get(inputBinding); } return node; }); resolver.hooks.afterResolve.tap("test", (value, node) => { if (node.type === "asset") { const { id } = node.value; if (id === "input") inputNode = node; if (id === "label") labelNode = node; } return value; }); resolver.update(); model.set([[inputBinding, "2022"]]); resolver.update(new Set([inputBinding])); // Check that label (which is cached) still points to the correct parent node. expect(labelNode?.parent).toBe(inputNode ?? {}); }); it("Fixes parent references when beforeResolve taps make changes", () => { const model = new LocalModel({ year: "2021", }); const parser = new Parser(); new AssetPlugin().applyParser(parser); const bindingParser = new BindingParser(); const rootNode = parser.parseObject(content); const resolver = new Resolver(rootNode!, { model, parseBinding: bindingParser.parse.bind(bindingParser), parseNode: parser.parseObject.bind(parser), evaluator: new ExpressionEvaluator({ model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), }); let parent; resolver.hooks.beforeResolve.tap("test", (node) => { if (node?.type !== NodeType.Asset || node.value.id !== "subtitle") { return node; } parent = node.parent; return { ...node, parent: undefined, }; }); let resolvedNode: Node.Node | undefined; resolver.hooks.afterResolve.tap("test", (resolvedValue, node) => { if (node?.type === NodeType.Asset && node.value.id === "subtitle") { resolvedNode = node; } return resolvedValue; }); resolver.update(); expect(parent).not.toBeUndefined(); expect(resolvedNode).not.toBeUndefined(); expect(resolvedNode?.parent).toBe(parent); }); }); describe("Duplicate IDs", () => { it("Throws an error if two assets have the same id", () => { const content = { id: "action", type: "collection", values: [ { asset: { id: "action-1", type: "action", label: { asset: { id: "action-label-1", type: "text", value: "Clicked {{count1}} times", }, }, }, }, { asset: { id: "action-1", type: "action", label: { asset: { id: "action-label-2", type: "text", value: "Clicked {{count2}} times", }, }, }, }, ], }; const model = new LocalModel({ count1: 0, count2: 0, }); const parser = new Parser(); new AssetPlugin().applyParser(parser); const bindingParser = new BindingParser(); const rootNode = parser.parseObject(content, NodeType.View); const logger = new TapableLogger(); const testLogger: Logger = { trace: vitest.fn(), debug: vitest.fn(), info: vitest.fn(), warn: vitest.fn(), error: vitest.fn(), }; logger.addHandler(testLogger); const resolver = new Resolver(rootNode!, { model, parseBinding: bindingParser.parse.bind(bindingParser), parseNode: parser.parseObject.bind(parser), evaluator: new ExpressionEvaluator({ model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), logger, }); new StringResolverPlugin().applyResolver(resolver); const firstUpdate = resolver.update(); expect(testLogger.error).toBeCalledTimes(1); expect(testLogger.error).toBeCalledWith( "Cache conflict: Found Asset/View nodes that have conflicting ids: action-1, may cause cache issues.", ); (testLogger.error as jest.Mock).mockClear(); expect(firstUpdate).toStrictEqual({ id: "action", type: "collection", values: [ { asset: { id: "action-1", type: "action", label: { asset: { id: "action-label-1", type: "text", value: "Clicked 0 times", }, }, }, }, { asset: { id: "action-1", type: "action", label: { asset: { id: "action-label-2", type: "text", value: "Clicked 0 times", }, }, }, }, ], }); resolver.update(); expect(testLogger.error).not.toBeCalled(); }); it("Throws a warning if two views have the same id", () => { const content = { id: "action", type: "collection", values: [ { id: "value-1", binding: "count1", }, { id: "value-1", binding: "count2", }, ], }; const model = new LocalModel({ count1: 0, count2: 0, }); const parser = new Parser(); new MultiNodePlugin().applyParser(parser); const bindingParser = new BindingParser(); const rootNode = parser.parseObject(content, NodeType.View); const logger = new TapableLogger(); const testLogger: Logger = { trace: vitest.fn(), debug: vitest.fn(), info: vitest.fn(), warn: vitest.fn(), error: vitest.fn(), }; logger.addHandler(testLogger); const resolver = new Resolver(rootNode!, { model, parseBinding: bindingParser.parse.bind(bindingParser), parseNode: parser.parseObject.bind(parser), evaluator: new ExpressionEvaluator({ model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), logger, }); new StringResolverPlugin().applyResolver(resolver); const firstUpdate = resolver.update(); expect(testLogger.info).toBeCalledTimes(1); expect(testLogger.info).toBeCalledWith( "Cache conflict: Found Value nodes that have conflicting ids: value-1, may cause cache issues. To improve performance make value node IDs globally unique.", ); (testLogger.info as jest.Mock).mockClear(); expect(firstUpdate).toStrictEqual(content); resolver.update(); expect(testLogger.info).not.toHaveBeenCalled(); }); }); describe("AST caching", () => { it("skipping resolution of nodes should still repopulate AST map for itself and children", () => { const content = { id: "collection", type: "collection", values: [ { id: "value-1", type: "collection", values: [ { id: "value-1-1", }, ], }, ], }; const model = new LocalModel(); const parser = new Parser(); new MultiNodePlugin().applyParser(parser); const bindingParser = new BindingParser(); const rootNode = parser.parseObject(content, NodeType.View); const resolver = new Resolver(rootNode!, { model, parseBinding: bindingParser.parse.bind(bindingParser), parseNode: parser.parseObject.bind(parser), evaluator: new ExpressionEvaluator({ model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), }); const resolvedNodes: any[] = []; resolver.hooks.afterResolve.tap("afterResolve", (value, node) => { resolvedNodes.push(node); return value; }); resolver.hooks.skipResolve.tap( "skipResolve", () => resolvedNodes.length >= 5, ); new StringResolverPlugin().applyResolver(resolver); expect(resolvedNodes).toHaveLength(0); resolver.update(); const frozenResolvedNodes = [...resolvedNodes]; expect(frozenResolvedNodes).toHaveLength(5); const sourceNodes = frozenResolvedNodes.map((node) => { const sourceNode = resolver.getSourceNode(node); expect(sourceNode).toBeDefined(); return sourceNode; }); resolver.update(); frozenResolvedNodes.forEach((node, index) => { const sourceNode = resolver.getSourceNode(node); expect(sourceNode).toBeDefined(); expect(sourceNode).toStrictEqual(sourceNodes[index]!); }); }); }); describe("Root AST Immutability", () => { it("modifying nodes in beforeResolve should not impact the original tree", () => { const content = { id: "action", type: "collection", values: [ { id: "value-1", binding: "count1", }, { id: "value-1", binding: "count2", }, ], }; const model = new LocalModel(); const parser = new Parser(); const bindingParser = new BindingParser(); const rootNode = parser.parseObject(content, NodeType.View); const resolver = new Resolver(rootNode!, { model, parseBinding: bindingParser.parse.bind(bindingParser), parseNode: parser.parseObject.bind(parser), evaluator: new ExpressionEvaluator({ model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), }); let finalNode; resolver.hooks.beforeResolve.tap("beforeResolve", (node) => { if (node?.type !== NodeType.View) return node; node.value.type = "not-collection"; return node; }); resolver.hooks.afterResolve.tap("afterResolve", (value, node) => { if (node?.type === NodeType.View) { finalNode = node; } return value; }); resolver.update(); expect(rootNode).toBe(resolver.root); expect(rootNode).not.toBe(finalNode); expect(finalNode).toMatchObject({ value: { type: "not-collection", }, }); expect(rootNode).toMatchObject({ value: { type: "collection", }, }); }); });