diff --git a/packages/forms/experimental/src/logic_node.ts b/packages/forms/experimental/src/logic_node.ts index da1a0a1ee259..c2ed29588804 100644 --- a/packages/forms/experimental/src/logic_node.ts +++ b/packages/forms/experimental/src/logic_node.ts @@ -33,11 +33,13 @@ export class FieldLogicNode { private readonly metadata = new Map, AbstractLogic>(); private readonly children = new Map(); + private readonly predicates: Predicate[]; - private constructor(private predicate: Predicate | undefined) { - this.hidden = new BooleanOrLogic(predicate); - this.disabled = new BooleanOrLogic(predicate); - this.errors = new ArrayMergeLogic(predicate); + private constructor(predicate: Predicate | undefined) { + this.predicates = predicate !== undefined ? [predicate] : []; + this.hidden = new BooleanOrLogic(this.predicates); + this.disabled = new BooleanOrLogic(this.predicates); + this.errors = new ArrayMergeLogic(this.predicates); } get element(): FieldLogicNode { @@ -46,7 +48,7 @@ export class FieldLogicNode { getMetadata(key: MetadataKey): AbstractLogic { if (!this.metadata.has(key as MetadataKey)) { - this.metadata.set(key as MetadataKey, new MetadataMergeLogic(this.predicate, key)); + this.metadata.set(key as MetadataKey, new MetadataMergeLogic(this.predicates, key)); } return this.metadata.get(key as MetadataKey)! as AbstractLogic; } @@ -64,7 +66,7 @@ export class FieldLogicNode { */ getChild(key: PropertyKey): FieldLogicNode { if (!this.children.has(key)) { - this.children.set(key, new FieldLogicNode(this.predicate)); + this.children.set(key, new FieldLogicNode(this.predicates[0])); } return this.children.get(key)!; } @@ -95,25 +97,25 @@ export class FieldLogicNode { export abstract class AbstractLogic { protected readonly fns: Array> = []; - constructor(private predicate: Predicate | undefined) {} + constructor(private predicates: ReadonlyArray) {} abstract compute(arg: FieldContext): TReturn; abstract get defaultValue(): TValue; push(logicFn: LogicFn) { - this.fns.push(wrapWithPredicate(this.predicate, logicFn, this.defaultValue)); + this.fns.push(wrapWithPredicates(this.predicates, logicFn, this.defaultValue)); } mergeIn(other: AbstractLogic) { - const fns = this.predicate - ? other.fns.map((fn) => wrapWithPredicate(this.predicate, fn, this.defaultValue)) + const fns = this.predicates + ? other.fns.map((fn) => wrapWithPredicates(this.predicates, fn, this.defaultValue)) : other.fns; this.fns.push(...fns); } } -class BooleanOrLogic extends AbstractLogic { +export class BooleanOrLogic extends AbstractLogic { override get defaultValue() { return false; } @@ -123,7 +125,7 @@ class BooleanOrLogic extends AbstractLogic { } } -class ArrayMergeLogic extends AbstractLogic< +export class ArrayMergeLogic extends AbstractLogic< TElement[], TElement | TElement[] | undefined > { @@ -146,16 +148,16 @@ class ArrayMergeLogic extends AbstractLogic< } } -class MetadataMergeLogic extends AbstractLogic { +export class MetadataMergeLogic extends AbstractLogic { override get defaultValue() { return this.key.defaultValue; } constructor( - predicate: Predicate | undefined, + predicates: ReadonlyArray, private key: MetadataKey, ) { - super(predicate); + super(predicates); } override compute(arg: FieldContext): T { @@ -163,19 +165,21 @@ class MetadataMergeLogic extends AbstractLogic { } } -function wrapWithPredicate( - predicate: Predicate | undefined, +function wrapWithPredicates( + predicates: ReadonlyArray, logicFn: LogicFn, defaultValue: TReturn, ) { - if (predicate === undefined) { + if (predicates.length === 0) { return logicFn; } return (arg: FieldContext): TReturn => { - const predicateField = arg.resolve(predicate.path).$state as FieldNode; - if (!predicate.fn(predicateField.fieldContext)) { - // don't actually run the user function - return defaultValue; + for (const predicate of predicates) { + const predicateField = arg.resolve(predicate.path).$state as FieldNode; + if (!predicate.fn(predicateField.fieldContext)) { + // don't actually run the user function + return defaultValue; + } } return logicFn(arg); }; diff --git a/packages/forms/experimental/src/logic_node_2.ts b/packages/forms/experimental/src/logic_node_2.ts new file mode 100644 index 000000000000..2bb735a5669e --- /dev/null +++ b/packages/forms/experimental/src/logic_node_2.ts @@ -0,0 +1,256 @@ +import {FieldContext, FormError, LogicFn, MetadataKey, ValidationResult} from '../public_api'; +import { + AbstractLogic, + ArrayMergeLogic, + BooleanOrLogic, + MetadataMergeLogic, + Predicate, +} from './logic_node'; + +abstract class AbstractLogicNodeBuilder { + abstract addHiddenRule(logic: LogicFn): void; + abstract addDisabledRule(logic: LogicFn): void; + abstract addErrorRule(logic: LogicFn): void; + abstract addMetadataRule(key: MetadataKey, logic: LogicFn): void; + abstract getChild(key: PropertyKey): LogicNodeBuilder; + + build(): LogicNode { + return new LeafLogicNode(this, []); + } +} + +/** + * A builder for `LogicNode`. Used to add logic to the final `LogicNode` tree. + */ +export class LogicNodeBuilder extends AbstractLogicNodeBuilder { + private current: NonMergableLogicNodeBuilder | undefined; + readonly all: {builder: AbstractLogicNodeBuilder; predicate?: Predicate}[] = []; + + addHiddenRule(logic: LogicFn): void { + this.getCurrent().addHiddenRule(logic); + } + + addDisabledRule(logic: LogicFn): void { + this.getCurrent().addDisabledRule(logic); + } + + addErrorRule(logic: LogicFn): void { + this.getCurrent().addErrorRule(logic); + } + + addMetadataRule(key: MetadataKey, logic: LogicFn): void { + this.getCurrent().addMetadataRule(key, logic); + } + + getChild(key: PropertyKey): LogicNodeBuilder { + return this.getCurrent().getChild(key); + } + + mergeIn(other: LogicNodeBuilder, predicate?: Predicate): void { + // Add the other builder to our collection, we'll defer the actual merging of the logic until + // the logic node is requested to be created. In order to preserve the original ordering of the + // rules, we close off the current builder to any further edits. If additional logic is added, + // a new current builder will be created to capture it. + if (predicate) { + this.all.push({builder: other, predicate}); + } else { + this.all.push({builder: other}); + } + this.current = undefined; + } + + private getCurrent(): NonMergableLogicNodeBuilder { + // All rules added to this builder get added on to the current builder. If there is no current + // builder, a new one is created. In order to preserve the original ordering of the rules, we + // clear the current builder whenever a separate builder tree is merged in. + if (this.current === undefined) { + this.current = new NonMergableLogicNodeBuilder(); + this.all.push({builder: this.current}); + } + return this.current; + } + + static newRoot(): LogicNodeBuilder { + return new LogicNodeBuilder(); + } +} + +/** + * A type of `AbstractLogicNodeBuilder` used internally by the `LogicNodeBuilder` to record "pure" + * chunks of logic that do not require merging in other builders. + */ +class NonMergableLogicNodeBuilder extends AbstractLogicNodeBuilder { + readonly logic = new Logic([]); + readonly children = new Map(); + + override addHiddenRule(logic: LogicFn): void { + this.logic.hidden.push(logic); + } + + override addDisabledRule(logic: LogicFn): void { + this.logic.disabled.push(logic); + } + + override addErrorRule(logic: LogicFn): void { + this.logic.errors.push(logic); + } + + override addMetadataRule(key: MetadataKey, logic: LogicFn): void { + this.logic.getMetadata(key).push(logic); + } + + override getChild(key: PropertyKey): LogicNodeBuilder { + if (!this.children.has(key)) { + this.children.set(key, new LogicNodeBuilder()); + } + return this.children.get(key)!; + } +} + +/** + * Container for all the different types of logic that can be applied to a field + * (disabled, hidden, errors, etc.) + */ +export class Logic { + readonly hidden: BooleanOrLogic; + readonly disabled: BooleanOrLogic; + readonly errors: ArrayMergeLogic; + private readonly metadata = new Map, AbstractLogic>(); + + constructor(private predicates: ReadonlyArray) { + this.hidden = new BooleanOrLogic(predicates); + this.disabled = new BooleanOrLogic(predicates); + this.errors = new ArrayMergeLogic(predicates); + } + + getMetadata(key: MetadataKey): AbstractLogic { + if (!this.metadata.has(key as MetadataKey)) { + this.metadata.set(key as MetadataKey, new MetadataMergeLogic(this.predicates, key)); + } + return this.metadata.get(key as MetadataKey)! as AbstractLogic; + } + + readMetadata(key: MetadataKey, arg: FieldContext): T { + if (this.metadata.has(key as MetadataKey)) { + return this.metadata.get(key as MetadataKey)!.compute(arg) as T; + } else { + return key.defaultValue; + } + } + + getMetadataKeys() { + return this.metadata.keys(); + } + + mergeIn(other: Logic) { + this.disabled.mergeIn(other.disabled); + this.hidden.mergeIn(other.hidden); + this.errors.mergeIn(other.errors); + for (const key of other.getMetadataKeys()) { + this.getMetadata(key).mergeIn(other.getMetadata(key)); + } + } +} + +export interface LogicNode { + readonly logic: Logic; + getChild(key: PropertyKey): LogicNode; +} + +/** + * A tree structure of `Logic` corresponding to a tree of fields. + */ +class LeafLogicNode implements LogicNode { + readonly logic: Logic; + + constructor( + private builder: AbstractLogicNodeBuilder | undefined, + private predicates: Predicate[], + ) { + this.logic = builder ? createLogic(builder, predicates) : new Logic([]); + } + + // TODO: cache here, or just rely on the user of this API to do caching? + getChild(key: PropertyKey): LogicNode { + // The logic for a particular child may be spread across multiple builders. We lazily combine + // this logic at the time the child logic node is requested to be created. + const childBuilders = this.builder ? getAllChildBuilders(this.builder, key) : []; + if (childBuilders.length <= 1) { + const {builder, predicates} = childBuilders[0]; + return new LeafLogicNode(builder, [...this.predicates, ...predicates]); + } else { + const builtNodes = childBuilders.map( + ({builder, predicates}) => new LeafLogicNode(builder, [...this.predicates, ...predicates]), + ); + return new CompositeLogicNode(builtNodes); + } + } +} + +class CompositeLogicNode implements LogicNode { + readonly logic: Logic; + + constructor(private all: LogicNode[]) { + this.logic = new Logic([]); + for (const node of all) { + this.logic.mergeIn(node.logic); + } + } + + getChild(key: PropertyKey): LogicNode { + return new CompositeLogicNode(this.all.flatMap((child) => child.getChild(key))); + } +} + +/** + * Gets all of the builders that contribute logic to the given child of the parent builder. + */ +function getAllChildBuilders( + builder: AbstractLogicNodeBuilder, + key: PropertyKey, +): {builder: LogicNodeBuilder; predicates: Predicate[]}[] { + if (builder instanceof LogicNodeBuilder) { + return builder.all.flatMap(({builder, predicate}) => { + const children = getAllChildBuilders(builder, key); + if (predicate) { + return children.map(({builder, predicates}) => ({ + builder, + predicates: [...predicates, predicate], + })); + } + return children; + }); + } else if (builder instanceof NonMergableLogicNodeBuilder) { + if (builder.children.has(key)) { + return [{builder: builder.children.get(key)!, predicates: []}]; + } + } else { + throw new Error('Unknown LogicNodeBuilder type'); + } + return []; +} + +/** + * Creates the full `Logic` for a given builder. + */ +function createLogic(builder: AbstractLogicNodeBuilder, predicates: Predicate[]): Logic { + const logic = new Logic(predicates); + if (builder instanceof LogicNodeBuilder) { + // TODO: do we need to bind predicate to a specific field here? + // Specifically I think we need to split the idea of a predicate in the LogicNodeBuilder from + // the idea of a predicate in the LogicNode. Instead of a path, the LogicNode version should + // have a field context. + const builtNodes = builder.all.map( + ({builder, predicate}) => + new LeafLogicNode(builder, predicate ? [...predicates, predicate] : predicates), + ); + for (const node of builtNodes) { + logic.mergeIn(node.logic); + } + } else if (builder instanceof NonMergableLogicNodeBuilder) { + logic.mergeIn(builder.logic); + } else { + throw new Error('Unknown LogicNodeBuilder type'); + } + return logic; +} diff --git a/packages/forms/experimental/test/logic_node_2.spec.ts b/packages/forms/experimental/test/logic_node_2.spec.ts new file mode 100644 index 000000000000..a0a4ba97be14 --- /dev/null +++ b/packages/forms/experimental/test/logic_node_2.spec.ts @@ -0,0 +1,396 @@ +import {signal} from '@angular/core'; +import {FieldContext} from '../public_api'; +import {DYNAMIC} from '../src/logic_node'; +import {LogicNodeBuilder} from '../src/logic_node_2'; + +const fakeFieldContext: FieldContext = { + resolve: () => + ({ + $state: {fieldContext: fakeFieldContext}, + }) as any, + value: undefined!, +}; + +describe('LogicNodeBuilder', () => { + it('should build logic', () => { + // (p) => { + // validate(p, () => ({kind: 'root-err'})); + // }; + + const builder = LogicNodeBuilder.newRoot(); + builder.addErrorRule(() => [{kind: 'root-err'}]); + + const logicNode = builder.build(); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([{kind: 'root-err'}]); + }); + + it('should build child logic', () => { + // (p) => { + // validate(p.a, () => ({kind: 'child-err'})); + // }; + + const builder = LogicNodeBuilder.newRoot(); + builder.getChild('a').addErrorRule(() => [{kind: 'root-err'}]); + + const logicNode = builder.build(); + expect(logicNode.getChild('a').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'root-err'}, + ]); + }); + + it('should build merged logic', () => { + // (p) => { + // validate(p, () => ({kind: 'err-1'})); + // validate(p, () => ({kind: 'err-2'})); + // }; + + const builder = LogicNodeBuilder.newRoot(); + builder.addErrorRule(() => [{kind: 'err-1'}]); + + const builder2 = LogicNodeBuilder.newRoot(); + builder2.addErrorRule(() => [{kind: 'err-2'}]); + builder.mergeIn(builder2); + + const logicNode = builder.build(); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + {kind: 'err-2'}, + ]); + }); + + it('should build merged child logic', () => { + // (p) => { + // validate(p.a, () => ({kind: 'err-1'})); + // validate(p.a, () => ({kind: 'err-2'})); + // }; + + const builder = LogicNodeBuilder.newRoot(); + builder.getChild('a').addErrorRule(() => [{kind: 'err-1'}]); + + const builder2 = LogicNodeBuilder.newRoot(); + builder2.getChild('a').addErrorRule(() => [{kind: 'err-2'}]); + builder.mergeIn(builder2); + + const logicNode = builder.build(); + expect(logicNode.getChild('a').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + {kind: 'err-2'}, + ]); + }); + + it('should build logic with predicate', () => { + // (p) => { + // applyWhen(p, pred, (p) => { + // validate(p, () => ({kind: 'err-1'})); + // }); + // } + + const builder = LogicNodeBuilder.newRoot(); + + const pred = signal(true); + const builder2 = LogicNodeBuilder.newRoot(); + builder2.addErrorRule(() => [{kind: 'err-1'}]); + builder.mergeIn(builder2, {fn: pred, path: undefined!}); + + const logicNode = builder.build(); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([{kind: 'err-1'}]); + + pred.set(false); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([]); + }); + + it('should apply predicate to merged in logic', () => { + // (p) => { + // applyWhen(p, pred, (p) => { + // apply(p, (p) => { + // validate(p, () => ({kind: 'err-1'})); + // }); + // }); + // } + + const builder = LogicNodeBuilder.newRoot(); + + const pred = signal(true); + const builder2 = LogicNodeBuilder.newRoot(); + + const builder3 = LogicNodeBuilder.newRoot(); + builder3.addErrorRule(() => [{kind: 'err-1'}]); + + builder2.mergeIn(builder3); + builder.mergeIn(builder2, {fn: pred, path: undefined!}); + + const logicNode = builder.build(); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([{kind: 'err-1'}]); + + pred.set(false); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([]); + }); + + it('should apply predicate to merged in child logic', () => { + // (p) => { + // applyWhen(p, pred, (p) => { + // apply(p, (p) => { + // validate(p.a, () => ({kind: 'err-1'})); + // }); + // }); + // } + + const builder = LogicNodeBuilder.newRoot(); + + const pred = signal(true); + const builder2 = LogicNodeBuilder.newRoot(); + + const builder3 = LogicNodeBuilder.newRoot(); + builder3.getChild('a').addErrorRule(() => [{kind: 'err-1'}]); + + builder2.mergeIn(builder3); + builder.mergeIn(builder2, {fn: pred, path: undefined!}); + + const logicNode = builder.build(); + expect(logicNode.getChild('a').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + ]); + + pred.set(false); + expect(logicNode.getChild('a').logic.errors.compute(fakeFieldContext)).toEqual([]); + }); + + it('should combine predicates', () => { + // (p) => { + // applyWhen(p, pred, (p) => { + // applyWhen(p.a, pred2, (a) => { + // validate(a, () => ({kind: 'err-1'})); + // }); + // }); + // } + + const builder = LogicNodeBuilder.newRoot(); + + const pred = signal(true); + const builder2 = LogicNodeBuilder.newRoot(); + + const pred2 = signal(true); + const builder3 = LogicNodeBuilder.newRoot(); + builder3.addErrorRule(() => [{kind: 'err-1'}]); + + builder2.getChild('a').mergeIn(builder3, {fn: pred2, path: undefined!}); + builder.mergeIn(builder2, {fn: pred, path: undefined!}); + + const logicNode = builder.build(); + expect(logicNode.getChild('a').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + ]); + + pred.set(false); + pred2.set(true); + expect(logicNode.getChild('a').logic.errors.compute(fakeFieldContext)).toEqual([]); + + pred.set(true); + pred2.set(false); + expect(logicNode.getChild('a').logic.errors.compute(fakeFieldContext)).toEqual([]); + }); + + it('should propagate predicates through deep application', () => { + // (p) => { + // applyWhen(p, pred, (p) => { + // validate(p.a.b, () => ({kind: 'err-1'})); + // applyWhen(p.a, pred2, (a) => { + // validate(a.b, () => ({kind: 'err-2'})); + // applyWhen(a.b, pred3, (b) => { + // validate(b, () => ({kind: 'err-3'})); + // }); + // }); + // }); + // } + + const builder = LogicNodeBuilder.newRoot(); + + const pred = signal(true); + const builder2 = LogicNodeBuilder.newRoot(); + builder2 + .getChild('a') + .getChild('b') + .addErrorRule(() => [{kind: 'err-1'}]); + + const pred2 = signal(true); + const builder3 = LogicNodeBuilder.newRoot(); + builder3.getChild('b').addErrorRule(() => [{kind: 'err-2'}]); + + const pred3 = signal(true); + const builder4 = LogicNodeBuilder.newRoot(); + builder4.addErrorRule(() => [{kind: 'err-3'}]); + builder3.getChild('b').mergeIn(builder4, {fn: pred3, path: undefined!}); + builder2.getChild('a').mergeIn(builder3, {fn: pred2, path: undefined!}); + builder.mergeIn(builder2, {fn: pred, path: undefined!}); + + const logicNode = builder.build(); + expect(logicNode.getChild('a').getChild('b').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + {kind: 'err-2'}, + {kind: 'err-3'}, + ]); + + pred.set(true); + pred2.set(true); + pred3.set(false); + expect(logicNode.getChild('a').getChild('b').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + {kind: 'err-2'}, + ]); + + pred.set(true); + pred2.set(false); + pred3.set(true); + expect(logicNode.getChild('a').getChild('b').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + ]); + + pred.set(false); + pred2.set(true); + pred3.set(true); + expect(logicNode.getChild('a').getChild('b').logic.errors.compute(fakeFieldContext)).toEqual( + [], + ); + }); + + it('should propagate predicates through deep child access', () => { + // (p) => { + // applyWhen(p, pred, (p) => { + // applyEach(p.items, (i) => { + // validate(i.last, () => ({kind: 'err-1'})); + // }); + // }); + // }; + + const builder = LogicNodeBuilder.newRoot(); + + const pred = signal(true); + const builder2 = LogicNodeBuilder.newRoot(); + + const builder3 = LogicNodeBuilder.newRoot(); + builder3.getChild('last').addErrorRule(() => [{kind: 'err-1'}]); + + builder2.getChild('items').getChild(DYNAMIC).mergeIn(builder3); + builder.mergeIn(builder2, {fn: pred, path: undefined!}); + + const logicNode = builder.build(); + expect( + logicNode + .getChild('items') + .getChild(DYNAMIC) + .getChild('last') + .logic.errors.compute(fakeFieldContext), + ).toEqual([{kind: 'err-1'}]); + + pred.set(false); + expect( + logicNode + .getChild('items') + .getChild(DYNAMIC) + .getChild('last') + .logic.errors.compute(fakeFieldContext), + ).toEqual([]); + }); + + it('should preserve ordering across merges', () => { + // (p) => { + // validate(p, () => ({kind: 'err-1'})); + // apply(p, (p) => { + // validate(p, () => ({kind: 'err-2'})); + // }) + // validate(p, () => ({kind: 'err-3'})); + // }; + + const builder = LogicNodeBuilder.newRoot(); + builder.addErrorRule(() => [{kind: 'err-1'}]); + + const builder2 = LogicNodeBuilder.newRoot(); + builder2.addErrorRule(() => [{kind: 'err-2'}]); + builder.mergeIn(builder2); + + builder.addErrorRule(() => [{kind: 'err-3'}]); + + const logicNode = builder.build(); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + {kind: 'err-2'}, + {kind: 'err-3'}, + ]); + }); + + it('should preserve child ordering across merges', () => { + // (p) => { + // validate(p.a, () => ({kind: 'err-1'})); + // apply(p, (p) => { + // validate(p.a, () => ({kind: 'err-2'})); + // }) + // validate(p.a, () => ({kind: 'err-3'})); + // }; + + const builder = LogicNodeBuilder.newRoot(); + builder.getChild('a').addErrorRule(() => [{kind: 'err-1'}]); + + const builder2 = LogicNodeBuilder.newRoot(); + builder2.getChild('a').addErrorRule(() => [{kind: 'err-2'}]); + builder.mergeIn(builder2); + + builder.getChild('a').addErrorRule(() => [{kind: 'err-3'}]); + + const logicNode = builder.build(); + expect(logicNode.getChild('a').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + {kind: 'err-2'}, + {kind: 'err-3'}, + ]); + }); + + it('should support circular logic structures', () => { + // const s = schema((p) => { + // validate(p, () => ({kind: 'err-1'})), + // apply(p.next, s); + // })); + + const builder = LogicNodeBuilder.newRoot(); + builder.addErrorRule(() => [{kind: 'err-1'}]); + builder.getChild('next').mergeIn(builder); + + const logicNode = builder.build(); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([{kind: 'err-1'}]); + expect(logicNode.getChild('next').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + ]); + expect( + logicNode.getChild('next').getChild('next').logic.errors.compute(fakeFieldContext), + ).toEqual([{kind: 'err-1'}]); + }); + + it('should support circular logic structures with predicate', () => { + // const s = schema((p) => { + // validate(p, () => ({kind: 'err-1'})), + // applyWhen(p.next, pred, s); + // })); + + const pred = signal(true); + const builder = LogicNodeBuilder.newRoot(); + builder.addErrorRule(() => [{kind: 'err-1'}]); + builder.getChild('next').mergeIn(builder, {fn: pred, path: undefined!}); + + const logicNode = builder.build(); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([{kind: 'err-1'}]); + expect(logicNode.getChild('next').logic.errors.compute(fakeFieldContext)).toEqual([ + {kind: 'err-1'}, + ]); + expect( + logicNode.getChild('next').getChild('next').logic.errors.compute(fakeFieldContext), + ).toEqual([{kind: 'err-1'}]); + + // TODO: test that verifies that the same predicate can resolve with a different field context + // on `.next` vs on `.next.next` + pred.set(false); + expect(logicNode.logic.errors.compute(fakeFieldContext)).toEqual([{kind: 'err-1'}]); + expect(logicNode.getChild('next').logic.errors.compute(fakeFieldContext)).toEqual([]); + expect( + logicNode.getChild('next').getChild('next').logic.errors.compute(fakeFieldContext), + ).toEqual([]); + }); +});