@legalplace/conditions-runner 2.2.20 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,185 @@
1
- import { Types } from "@legalplace/referencesparser";
1
+ import { Types, extractConditionDataMap, lowerScope } from "@legalplace/referencesparser";
2
+ import type { ConditionV3 } from "@legalplace/models-v3-types";
2
3
  import ConditionsRunner from "./ConditionsRunner";
4
+ import {
5
+ CONDITION_COMPARATOR_EMPTY_SENTINEL,
6
+ resolveConditionComparators,
7
+ } from "./ComparatorResolver";
3
8
  import dummyInputs from "./fixtures/inputs-type.json";
4
9
  import dummyReferences from "./fixtures/references.json";
10
+ import type { ConditionDataMap } from "./types/conditions.d";
11
+
12
+ const emptyOptionRelations = {
13
+ options: [],
14
+ variables: [],
15
+ sections: {},
16
+ documents: [] as string[],
17
+ prefillers: [],
18
+ validators: { options: [], variables: [] },
19
+ parents: [] as number[],
20
+ children: { options: [] as number[], variables: [] as number[] },
21
+ dependants: [] as number[],
22
+ };
23
+
24
+ /**
25
+ * Builds a minimal references graph: multiple #2 with radio children 3/4.
26
+ */
27
+ const buildRelatedDocumentReferences = (): Types.ReferencesType =>
28
+ ({
29
+ options: {
30
+ 2: {
31
+ meta: {
32
+ id: 2,
33
+ type: "static",
34
+ label: "Partner",
35
+ step: "*",
36
+ multiple: { enabled: true },
37
+ },
38
+ options: [3, 4],
39
+ variables: [],
40
+ },
41
+ 3: {
42
+ meta: { id: 3, type: "radio", label: "Yes", step: "*" },
43
+ options: [],
44
+ variables: [],
45
+ },
46
+ 4: {
47
+ meta: { id: 4, type: "radio", label: "No", step: "*" },
48
+ options: [],
49
+ variables: [],
50
+ },
51
+ },
52
+ variables: {},
53
+ outputs: {},
54
+ boxes: {},
55
+ documents: {
56
+ annex: {
57
+ name: "Annex",
58
+ params: { multiple: { enabled: true, repeatOption: 2 } },
59
+ },
60
+ },
61
+ sections: {},
62
+ relations: {
63
+ options: {
64
+ 2: {
65
+ ...emptyOptionRelations,
66
+ children: { options: [3, 4], variables: [] },
67
+ },
68
+ 3: { ...emptyOptionRelations, parents: [2] },
69
+ 4: { ...emptyOptionRelations, parents: [2] },
70
+ },
71
+ variables: {},
72
+ sections: {},
73
+ },
74
+ conditions: {
75
+ options: {},
76
+ variables: {},
77
+ documents: {},
78
+ sections: { main: {} },
79
+ prefillers: {},
80
+ validators: { options: {}, variables: {} },
81
+ },
82
+ defaults: {},
83
+ evaluations: {},
84
+ optionTags: {},
85
+ variableTags: {},
86
+ } as unknown as Types.ReferencesType);
87
+
88
+ /**
89
+ * Multiple #10 hosting sibling variables 3 and 4 (same root parent).
90
+ */
91
+ const buildSiblingVariableReferences = (): Types.ReferencesType =>
92
+ ({
93
+ options: {
94
+ 10: {
95
+ meta: {
96
+ id: 10,
97
+ type: "static",
98
+ label: "Repeat",
99
+ step: "*",
100
+ multiple: { enabled: true },
101
+ },
102
+ options: [],
103
+ variables: [3, 4],
104
+ },
105
+ 20: {
106
+ meta: { id: 20, type: "static", label: "Other", step: "*" },
107
+ options: [],
108
+ variables: [5],
109
+ },
110
+ },
111
+ variables: {
112
+ 3: { meta: { id: 3, type: "string", label: "Left" }, options: [] },
113
+ 4: { meta: { id: 4, type: "string", label: "Right" }, options: [] },
114
+ 5: { meta: { id: 5, type: "string", label: "Elsewhere" }, options: [] },
115
+ },
116
+ outputs: {},
117
+ boxes: {},
118
+ documents: {},
119
+ sections: {},
120
+ relations: {
121
+ options: {
122
+ 10: {
123
+ ...emptyOptionRelations,
124
+ children: { options: [], variables: [3, 4] },
125
+ },
126
+ 20: {
127
+ ...emptyOptionRelations,
128
+ children: { options: [], variables: [5] },
129
+ },
130
+ },
131
+ variables: {
132
+ 3: { ...emptyOptionRelations, parents: [10] },
133
+ 4: { ...emptyOptionRelations, parents: [10] },
134
+ 5: { ...emptyOptionRelations, parents: [20] },
135
+ },
136
+ sections: {},
137
+ },
138
+ conditions: {
139
+ options: {},
140
+ variables: {},
141
+ documents: {},
142
+ sections: { main: {} },
143
+ prefillers: {},
144
+ validators: { options: {}, variables: {} },
145
+ },
146
+ defaults: {},
147
+ evaluations: {},
148
+ optionTags: {},
149
+ variableTags: {},
150
+ } as unknown as Types.ReferencesType);
151
+
152
+ const relatedOvc = {
153
+ options: {
154
+ 2: [true, true],
155
+ 3: [true, false],
156
+ 4: [false, true],
157
+ },
158
+ variables: {},
159
+ };
160
+
161
+ const siblingOvc = {
162
+ options: {
163
+ 10: [true, true, true],
164
+ 20: [true],
165
+ },
166
+ variables: {
167
+ 3: ["same", "b", "c"],
168
+ 4: ["same", "y", "c"],
169
+ 5: ["solo-a", "solo-b"],
170
+ },
171
+ };
172
+
173
+ /**
174
+ * Builds executeCondition input from an authored ConditionV3 (scope on the node).
175
+ * @param conditions Authored condition tree
176
+ */
177
+ const mappedCondition = (
178
+ conditions: ConditionV3
179
+ ): { dataMap: ConditionDataMap; conditions: ConditionV3 } => ({
180
+ dataMap: extractConditionDataMap(conditions),
181
+ conditions,
182
+ });
5
183
 
6
184
  describe("ConditionsRunner class", () => {
7
185
  it("should not raise error when instanciating class", () => {
@@ -12,4 +190,186 @@ describe("ConditionsRunner class", () => {
12
190
  )
13
191
  ).toBeDefined();
14
192
  });
193
+
194
+ it("slices related document conditions to the current occurrence", () => {
195
+ const runner = new ConditionsRunner(
196
+ buildRelatedDocumentReferences(),
197
+ relatedOvc
198
+ );
199
+ const condition = mappedCondition({
200
+ selected: [{ var: "o.3" }],
201
+ scope: "related",
202
+ });
203
+
204
+ expect(runner.executeCondition(condition, 2, 0, "documents")).toBe(true);
205
+ expect(runner.executeCondition(condition, 2, 1, "documents")).toBe(false);
206
+ });
207
+
208
+ it("evaluates selected + first against the first row after id/index slicing", () => {
209
+ const runner = new ConditionsRunner(
210
+ buildRelatedDocumentReferences(),
211
+ relatedOvc
212
+ );
213
+ const condition = mappedCondition({
214
+ selected: [{ var: "o.3" }],
215
+ scope: "first",
216
+ });
217
+
218
+ expect(runner.executeCondition(condition, 2, 1, "documents")).toBe(true);
219
+ });
220
+
221
+ it("related sibling [var:N] uses the current occurrence index", () => {
222
+ const runner = new ConditionsRunner(
223
+ buildSiblingVariableReferences(),
224
+ siblingOvc
225
+ );
226
+ const condition = mappedCondition({
227
+ "=": [{ var: "v.3" }, "[var:4]"],
228
+ scope: "related",
229
+ });
230
+
231
+ // index 0: "same" === "same"
232
+ expect(runner.executeCondition(condition, 3, 0, "variables")).toBe(true);
233
+ // index 1: "b" === "y"
234
+ expect(runner.executeCondition(condition, 3, 1, "variables")).toBe(false);
235
+ // index 2: "c" === "c" (not "same" from occurrence 0)
236
+ expect(runner.executeCondition(condition, 3, 2, "variables")).toBe(true);
237
+ });
238
+
239
+ it("first-scoped sibling [var:N] uses occurrence 0", () => {
240
+ const runner = new ConditionsRunner(
241
+ buildSiblingVariableReferences(),
242
+ siblingOvc
243
+ );
244
+ const condition = mappedCondition({
245
+ "=": [{ var: "v.3" }, "[var:4]"],
246
+ scope: "first",
247
+ });
248
+
249
+ // Outer index 2 still binds both sides to occurrence 0 → "same" === "same"
250
+ expect(runner.executeCondition(condition, 3, 2, "variables")).toBe(true);
251
+ });
252
+
253
+ it("all-scope sibling [var:N] is pairwise true when every row matches", () => {
254
+ const runner = new ConditionsRunner(buildSiblingVariableReferences(), {
255
+ options: { 10: [true, true], 20: [true] },
256
+ variables: {
257
+ 3: [1, 2],
258
+ 4: [2, 3],
259
+ 5: ["solo-a"],
260
+ },
261
+ });
262
+ const condition = mappedCondition({
263
+ "<": [{ var: "v.3" }, "[var:4]"],
264
+ scope: "all",
265
+ });
266
+
267
+ expect(runner.executeCondition(condition, 3, 0, "options")).toBe(true);
268
+ });
269
+
270
+ it("all-scope sibling [var:N] is pairwise false when a row fails", () => {
271
+ const runner = new ConditionsRunner(buildSiblingVariableReferences(), {
272
+ options: { 10: [true, true], 20: [true] },
273
+ variables: {
274
+ 3: [1, 5],
275
+ 4: [2, 3],
276
+ 5: ["solo-a"],
277
+ },
278
+ });
279
+ const condition = mappedCondition({
280
+ "<": [{ var: "v.3" }, "[var:4]"],
281
+ scope: "all",
282
+ });
283
+
284
+ expect(runner.executeCondition(condition, 3, 0, "options")).toBe(false);
285
+ });
286
+
287
+ it("any-scope sibling [var:N] succeeds on a later pairwise row", () => {
288
+ const runner = new ConditionsRunner(buildSiblingVariableReferences(), {
289
+ options: { 10: [true, true], 20: [true] },
290
+ variables: {
291
+ 3: [5, 1],
292
+ 4: [2, 10],
293
+ 5: ["solo-a"],
294
+ },
295
+ });
296
+ const condition = mappedCondition({
297
+ "<": [{ var: "v.3" }, "[var:4]"],
298
+ scope: "any",
299
+ });
300
+
301
+ expect(runner.executeCondition(condition, 5, 0, "options")).toBe(true);
302
+ });
303
+
304
+ it("joins non-sibling [var:N] values instead of slicing by row", () => {
305
+ const references = buildSiblingVariableReferences();
306
+ const resolved = resolveConditionComparators(
307
+ lowerScope({
308
+ "=": [{ var: "v.3" }, "[var:5]"],
309
+ scope: "related",
310
+ }),
311
+ { optionId: 10, index: 2, conditionVariableId: 3 },
312
+ { references, ovc: siblingOvc }
313
+ );
314
+
315
+ expect(resolved).toEqual({
316
+ "=": [{ var: "v.3" }, "solo-a, solo-b"],
317
+ });
318
+ });
319
+ });
320
+
321
+ describe("resolveConditionComparators", () => {
322
+ const references = buildSiblingVariableReferences();
323
+ const deps = { references, ovc: siblingOvc };
324
+
325
+ it("related leaf: sibling [var:N] uses the current occurrence index", () => {
326
+ const resolved = resolveConditionComparators(
327
+ lowerScope({
328
+ "=": [{ var: "v.3" }, "[var:4]"],
329
+ scope: "related",
330
+ }),
331
+ { optionId: 10, index: 2, conditionVariableId: 3 },
332
+ deps
333
+ );
334
+
335
+ expect(resolved).toEqual({
336
+ "=": [{ var: "v.3" }, "c"],
337
+ });
338
+ });
339
+
340
+ it("first-scoped leaf: sibling [var:N] uses occurrence 0", () => {
341
+ const resolved = resolveConditionComparators(
342
+ lowerScope({
343
+ "=": [{ var: "v.3" }, "[var:4]"],
344
+ scope: "first",
345
+ }),
346
+ { optionId: 10, index: 2, conditionVariableId: 3 },
347
+ deps
348
+ );
349
+
350
+ expect(resolved).toEqual({
351
+ "=-first": [{ var: "v.3" }, "same"],
352
+ });
353
+ });
354
+
355
+ it("uses empty sentinel for missing sibling values", () => {
356
+ const resolved = resolveConditionComparators(
357
+ lowerScope({
358
+ "=": [{ var: "v.3" }, "[var:4]"],
359
+ scope: "related",
360
+ }),
361
+ { optionId: 10, index: 5, conditionVariableId: 3 },
362
+ {
363
+ references,
364
+ ovc: {
365
+ options: siblingOvc.options,
366
+ variables: { 3: ["a"], 4: ["b"], 5: [] },
367
+ },
368
+ }
369
+ );
370
+
371
+ expect(resolved).toEqual({
372
+ "=": [{ var: "v.3" }, CONDITION_COMPARATOR_EMPTY_SENTINEL],
373
+ });
374
+ });
15
375
  });
@@ -1,10 +1,17 @@
1
1
  /* eslint-disable max-len */
2
2
  import LpLogic from "@legalplace/lplogic";
3
3
  import type { Types } from "@legalplace/referencesparser";
4
+ import { lowerScope } from "@legalplace/referencesparser";
4
5
  import type { ConditionV3 } from "@legalplace/models-v3-types";
5
6
  import type InputsType from "@legalplace/types/dist/inputs";
6
7
  import DataPopulator from "./DataPopulator";
7
8
  import type { ConditionDataMap } from "./types/conditions.d";
9
+ import { aggregateScopedLeaves } from "./ScopedLeafEvaluator";
10
+ import {
11
+ getConditionVariableOptionId,
12
+ resolveConditionComparators,
13
+ type ComparatorResolverContext,
14
+ } from "./ComparatorResolver";
8
15
 
9
16
  class ConditionsRunner {
10
17
  private references: Types.ReferencesType;
@@ -171,10 +178,64 @@ class ConditionsRunner {
171
178
  ).getData();
172
179
  }
173
180
 
174
- const result = LpLogic(condition.conditions, currentData);
181
+ const resolverContext = this.buildComparatorResolverContext(
182
+ id,
183
+ index,
184
+ conditionType
185
+ );
186
+ const deps = { references: this.references, ovc: this.ovc };
187
+
188
+ // Pairwise sibling `[var:N]` (all/any/first) must run before string
189
+ // substitution collapses the tag to a single value.
190
+ const aggregatedConditions = aggregateScopedLeaves(
191
+ lowerScope(condition.conditions),
192
+ new DataPopulator(
193
+ this,
194
+ this.references,
195
+ this.ovc,
196
+ condition.dataMap
197
+ ).getData(),
198
+ resolverContext,
199
+ deps
200
+ );
201
+
202
+ const resolvedConditions = resolveConditionComparators(
203
+ aggregatedConditions,
204
+ resolverContext,
205
+ deps
206
+ );
207
+
208
+ const result = LpLogic(resolvedConditions, currentData);
175
209
  this.executeConditionMemo[memoizedKey] = result;
176
210
  return result;
177
211
  }
212
+
213
+ /**
214
+ * Builds option/index context for comparator `[var:N]` substitution.
215
+ */
216
+ private buildComparatorResolverContext(
217
+ id: number,
218
+ index: number,
219
+ conditionType: string
220
+ ): ComparatorResolverContext {
221
+ if (conditionType === "options" || conditionType === "optionValidator") {
222
+ return { optionId: id, index, conditionOptionId: id };
223
+ }
224
+
225
+ if (
226
+ conditionType === "variables" ||
227
+ conditionType === "prefillers" ||
228
+ conditionType === "variableValidator"
229
+ ) {
230
+ return {
231
+ optionId: getConditionVariableOptionId(this.references, id),
232
+ index,
233
+ conditionVariableId: id,
234
+ };
235
+ }
236
+
237
+ return { optionId: id, index };
238
+ }
178
239
  }
179
240
 
180
241
  export default ConditionsRunner;
@@ -169,7 +169,7 @@ class DataPopulator {
169
169
  private getVariableValues(variableId: number): Readonly<(string | number)[]> {
170
170
  const value = this.ovc.variables[variableId] || [];
171
171
  if (this.id === undefined || this.index === undefined) {
172
- return value;
172
+ return this.cleanArrayFromNullValues([...value]);
173
173
  }
174
174
 
175
175
  // Getting variable parent option
@@ -181,7 +181,7 @@ class DataPopulator {
181
181
  variableRootRelations.dependants.includes(this.id) ||
182
182
  this.id === variableRootParent
183
183
  ) {
184
- return [value[this.index]];
184
+ return this.cleanArrayFromNullValues([value[this.index]]);
185
185
  }
186
186
 
187
187
  return this.cleanArrayFromNullValues([...value]);
@@ -203,9 +203,9 @@ class DataPopulator {
203
203
  * @param arr Values array
204
204
  */
205
205
  private cleanArrayFromNullValues(
206
- arr: (string | number | null)[]
206
+ arr: (string | number | null | undefined)[]
207
207
  ): (string | number)[] {
208
- return arr.map((v) => (v === null ? "" : v));
208
+ return arr.map((v) => (v === null || v === undefined ? "" : v));
209
209
  }
210
210
 
211
211
  /**