@servicetitan/dte-pdf-editor 1.45.0 → 1.47.0
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.
- package/README.md +15 -0
- package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
- package/dist/components/field-config-panel/field-config-panel.js +4 -2
- package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
- package/dist/components/pdf-fields-overlay/pdf-fields-overlay.d.ts.map +1 -1
- package/dist/components/pdf-fields-overlay/pdf-fields-overlay.js +11 -1
- package/dist/components/pdf-fields-overlay/pdf-fields-overlay.js.map +1 -1
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.d.ts +1 -0
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.d.ts.map +1 -1
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.js +14 -2
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.js.map +1 -1
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.d.ts.map +1 -1
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js +3 -0
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js.map +1 -1
- package/dist/components/pdf-fields-overlay/pdf-overlay-field.d.ts +1 -0
- package/dist/components/pdf-fields-overlay/pdf-overlay-field.d.ts.map +1 -1
- package/dist/components/pdf-fields-overlay/pdf-overlay-field.js +2 -2
- package/dist/components/pdf-fields-overlay/pdf-overlay-field.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.d.ts +2 -1
- package/dist/components/pdf-view/pdf-view-calculated.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.js +5 -2
- package/dist/components/pdf-view/pdf-view-calculated.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.d.ts +10 -2
- package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.js +16 -12
- package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
- package/dist/components/pdf-view/pdf-view.d.ts +2 -2
- package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view.js +4 -4
- package/dist/components/pdf-view/pdf-view.js.map +1 -1
- package/dist/interface/types.d.ts +2 -1
- package/dist/interface/types.d.ts.map +1 -1
- package/dist/interface/types.js.map +1 -1
- package/dist/utils/formula/expression.utils.d.ts +13 -0
- package/dist/utils/formula/expression.utils.d.ts.map +1 -1
- package/dist/utils/formula/expression.utils.js +42 -0
- package/dist/utils/formula/expression.utils.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/field-types/date-fields.test.ts +212 -0
- package/src/__tests__/field-types/display-condition-fields.test.ts +340 -0
- package/src/__tests__/field-types/e-sign-fields.test.ts +97 -0
- package/src/__tests__/field-types/fillable-fields.test.ts +180 -0
- package/src/__tests__/field-types/form-fields.test.ts +193 -0
- package/src/__tests__/field-types/formula-fields.test.ts +245 -0
- package/src/__tests__/field-types/merge-tag-fields.test.ts +208 -0
- package/src/components/field-config-panel/field-config-panel.tsx +11 -0
- package/src/components/pdf-fields-overlay/pdf-fields-overlay.tsx +12 -1
- package/src/components/pdf-fields-overlay/pdf-overlay-field-calculated.tsx +20 -4
- package/src/components/pdf-fields-overlay/pdf-overlay-field-fillable.tsx +10 -0
- package/src/components/pdf-fields-overlay/pdf-overlay-field.tsx +5 -1
- package/src/components/pdf-view/pdf-view-calculated.tsx +13 -3
- package/src/components/pdf-view/pdf-view-fillable.tsx +62 -21
- package/src/components/pdf-view/pdf-view.tsx +11 -10
- package/src/interface/types.ts +12 -10
- package/src/styles/generic.css +6 -0
- package/src/styles/inline-editable.css +1 -0
- package/src/utils/conditions/__tests__/evaluate.utils.test.ts +163 -0
- package/src/utils/conditions/__tests__/schema-data-points.utils.test.ts +149 -0
- package/src/utils/data-model/__tests__/extract-fields.utils.test.ts +154 -0
- package/src/utils/data-model/__tests__/resolve-values.utils.test.ts +60 -0
- package/src/utils/field/__tests__/field-background-color.utils.test.ts +26 -0
- package/src/utils/field/__tests__/field-placeholder-text.utils.test.ts +46 -0
- package/src/utils/formula/__tests__/dom.utils.test.ts +33 -0
- package/src/utils/formula/__tests__/evaluate-formula.utils.test.ts +202 -0
- package/src/utils/formula/__tests__/expression.utils.test.ts +119 -0
- package/src/utils/formula/__tests__/form-fields.utils.test.ts +274 -0
- package/src/utils/formula/__tests__/format-calculated-result.utils.test.ts +105 -0
- package/src/utils/formula/__tests__/render-formula.utils.test.ts +43 -0
- package/src/utils/formula/__tests__/validate-formula.utils.test.ts +168 -0
- package/src/utils/formula/expression.utils.ts +44 -0
- package/src/utils/path/__tests__/generate-e-sign-path.test.ts +26 -0
- package/src/utils/path/__tests__/generate-fillable-path.test.ts +17 -0
- package/src/utils/path/__tests__/parse-fillable-path.test.ts +25 -0
- package/src/utils/recipients/__tests__/map-colors.test.ts +40 -0
- package/src/utils/shared/__tests__/date.utils.test.ts +58 -0
- package/src/utils/shared/__tests__/number.utils.test.ts +48 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { FieldTypeEnum, SchemaObject } from '../../../interface/types';
|
|
2
|
+
import { extractGroupedFieldsFromDataModel } from '../extract-fields.utils';
|
|
3
|
+
|
|
4
|
+
const emptySchema: SchemaObject = { type: 'object', properties: {} };
|
|
5
|
+
|
|
6
|
+
describe('extractGroupedFieldsFromDataModel', () => {
|
|
7
|
+
const subject = (
|
|
8
|
+
schema: SchemaObject,
|
|
9
|
+
options?: Parameters<typeof extractGroupedFieldsFromDataModel>[1],
|
|
10
|
+
) => extractGroupedFieldsFromDataModel(schema, options);
|
|
11
|
+
|
|
12
|
+
describe('when schema has no properties', () => {
|
|
13
|
+
test('returns empty array', () => {
|
|
14
|
+
expect(subject(emptySchema)).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('with top-level simple fields', () => {
|
|
19
|
+
const schema: SchemaObject = {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
firstName: { type: 'string', title: 'First Name' },
|
|
23
|
+
age: { type: 'number' },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test('groups simple fields under the default "Fields" group', () => {
|
|
28
|
+
const result = subject(schema);
|
|
29
|
+
expect(result).toHaveLength(1);
|
|
30
|
+
expect(result[0].groupName).toBe('Fields');
|
|
31
|
+
expect(result[0].fields).toEqual([
|
|
32
|
+
{
|
|
33
|
+
label: 'First Name',
|
|
34
|
+
type: FieldTypeEnum.dataModel,
|
|
35
|
+
path: 'firstName',
|
|
36
|
+
fieldType: undefined,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: 'age',
|
|
40
|
+
type: FieldTypeEnum.dataModel,
|
|
41
|
+
path: 'age',
|
|
42
|
+
fieldType: 'number',
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('with object properties', () => {
|
|
49
|
+
const schema: SchemaObject = {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
user: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
title: 'User Info',
|
|
55
|
+
properties: {
|
|
56
|
+
name: { type: 'string', title: 'Name' },
|
|
57
|
+
address: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
city: { type: 'string', title: 'City' },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
test('flattens nested object properties into one group per top-level object', () => {
|
|
69
|
+
const result = subject(schema);
|
|
70
|
+
expect(result).toHaveLength(1);
|
|
71
|
+
expect(result[0].groupName).toBe('User Info');
|
|
72
|
+
expect(result[0].fields).toEqual([
|
|
73
|
+
{
|
|
74
|
+
label: 'Name',
|
|
75
|
+
type: FieldTypeEnum.dataModel,
|
|
76
|
+
path: 'user.name',
|
|
77
|
+
fieldType: undefined,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
label: 'City',
|
|
81
|
+
type: FieldTypeEnum.dataModel,
|
|
82
|
+
path: 'user.address.city',
|
|
83
|
+
fieldType: undefined,
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('places default-group fields before object groups', () => {
|
|
90
|
+
const schema: SchemaObject = {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
top: { type: 'string' },
|
|
94
|
+
user: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
title: 'User',
|
|
97
|
+
properties: { name: { type: 'string' } },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const result = subject(schema);
|
|
102
|
+
expect(result.map(g => g.groupName)).toEqual(['Fields', 'User']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('skips array properties', () => {
|
|
106
|
+
const schema: SchemaObject = {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
items: { type: 'array', items: { type: 'string' } },
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
expect(subject(schema)).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('skips empty object groups', () => {
|
|
116
|
+
const schema: SchemaObject = {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
empty: { type: 'object', properties: {} },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
expect(subject(schema)).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('with onlyUseInCalculatedFields option', () => {
|
|
126
|
+
const schema: SchemaObject = {
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: {
|
|
129
|
+
included: {
|
|
130
|
+
type: 'number',
|
|
131
|
+
options: { useInCalculatedFields: true },
|
|
132
|
+
},
|
|
133
|
+
excluded: { type: 'number' },
|
|
134
|
+
user: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
title: 'User',
|
|
137
|
+
properties: {
|
|
138
|
+
salary: {
|
|
139
|
+
type: 'number',
|
|
140
|
+
options: { useInCalculatedFields: true },
|
|
141
|
+
},
|
|
142
|
+
notes: { type: 'string' },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
test('keeps only fields flagged with useInCalculatedFields', () => {
|
|
149
|
+
const result = subject(schema, { onlyUseInCalculatedFields: true });
|
|
150
|
+
const allPaths = result.flatMap(g => g.fields.map(f => f.path));
|
|
151
|
+
expect(allPaths.sort()).toEqual(['included', 'user.salary']);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DataModelValues } from '../../../interface/types';
|
|
2
|
+
import { resolvePdfDataValues } from '../resolve-values.utils';
|
|
3
|
+
|
|
4
|
+
describe('resolvePdfDataValues', () => {
|
|
5
|
+
const subject = (data: DataModelValues | undefined, path: string | undefined) =>
|
|
6
|
+
resolvePdfDataValues(data, path);
|
|
7
|
+
|
|
8
|
+
describe('when data or path are missing', () => {
|
|
9
|
+
test.each([
|
|
10
|
+
['data is undefined', undefined, 'some.path'],
|
|
11
|
+
['path is undefined', { foo: 'bar' }, undefined],
|
|
12
|
+
['path is empty', { foo: 'bar' }, ''],
|
|
13
|
+
])('returns empty string when %s', (_, data, path) => {
|
|
14
|
+
expect(subject(data as DataModelValues | undefined, path)).toBe('');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('with primitive resolution', () => {
|
|
19
|
+
test('resolves a top-level string property', () => {
|
|
20
|
+
expect(subject({ name: 'John' }, 'name')).toBe('John');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('resolves a nested string property', () => {
|
|
24
|
+
expect(subject({ user: { name: 'John' } }, 'user.name')).toBe('John');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('resolves a numeric property as string', () => {
|
|
28
|
+
expect(subject({ count: 42 }, 'count')).toBe('42');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('resolves a boolean property as string', () => {
|
|
32
|
+
expect(subject({ active: true }, 'active')).toBe('true');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('when value is missing', () => {
|
|
37
|
+
test.each([
|
|
38
|
+
['null value', { user: null }, 'user.name'],
|
|
39
|
+
['undefined value', { user: undefined }, 'user.name'],
|
|
40
|
+
['unknown key', { user: { name: 'John' } }, 'user.email'],
|
|
41
|
+
['intermediate value is not object', { user: 'string' }, 'user.name'],
|
|
42
|
+
])('returns empty string for %s', (_, data, path) => {
|
|
43
|
+
expect(subject(data as DataModelValues, path)).toBe('');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('with object value resolution', () => {
|
|
48
|
+
test('formats amount/currency objects', () => {
|
|
49
|
+
expect(subject({ price: { Amount: 100, Currency: 'USD' } }, 'price')).toBe('USD 100');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('returns Value property when present', () => {
|
|
53
|
+
expect(subject({ field: { Value: 'inner' } }, 'field')).toBe('inner');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('stringifies generic objects via JSON', () => {
|
|
57
|
+
expect(subject({ obj: { foo: 'bar' } }, 'obj')).toBe('{"foo":"bar"}');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getFieldBackgroundColor } from '../field-background-color.utils';
|
|
2
|
+
|
|
3
|
+
describe('getFieldBackgroundColor', () => {
|
|
4
|
+
const subject = (recipient: string | undefined, recipientsColors: Record<string, string>) =>
|
|
5
|
+
getFieldBackgroundColor(recipient, recipientsColors);
|
|
6
|
+
|
|
7
|
+
test('returns color for recipient when present in map', () => {
|
|
8
|
+
expect(subject('signer1', { signer1: '#ff0000', signer2: '#00ff00' })).toBe('#ff0000');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('returns "none" when recipient is undefined', () => {
|
|
12
|
+
expect(subject(undefined, { signer1: '#ff0000' })).toBe('none');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('returns "none" when recipient is empty string', () => {
|
|
16
|
+
expect(subject('', { signer1: '#ff0000' })).toBe('none');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('returns "none" when recipient is not in the color map', () => {
|
|
20
|
+
expect(subject('unknown', { signer1: '#ff0000' })).toBe('none');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('returns "none" when color map is empty', () => {
|
|
24
|
+
expect(subject('signer1', {})).toBe('none');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { FieldTypeEnum, PdfField } from '../../../interface/types';
|
|
2
|
+
import { getFieldPlaceholderText } from '../field-placeholder-text.utils';
|
|
3
|
+
|
|
4
|
+
const buildField = (overrides: Partial<PdfField> = {}): PdfField => ({
|
|
5
|
+
id: 'field-1',
|
|
6
|
+
type: FieldTypeEnum.fillable,
|
|
7
|
+
x: 0,
|
|
8
|
+
y: 0,
|
|
9
|
+
page: 1,
|
|
10
|
+
label: 'Field Label',
|
|
11
|
+
width: 100,
|
|
12
|
+
height: 25,
|
|
13
|
+
...overrides,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('getFieldPlaceholderText', () => {
|
|
17
|
+
const subject = (field: PdfField, prefix?: string) => getFieldPlaceholderText(field, prefix);
|
|
18
|
+
|
|
19
|
+
test('returns label when no prefix and not required', () => {
|
|
20
|
+
expect(subject(buildField())).toBe('Field Label');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('appends asterisk when field is required', () => {
|
|
24
|
+
expect(subject(buildField({ required: true }))).toBe('Field Label *');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('prepends prefix when provided', () => {
|
|
28
|
+
expect(subject(buildField(), 'Enter')).toBe('Enter Field Label');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('joins prefix, label, and required asterisk in order', () => {
|
|
32
|
+
expect(subject(buildField({ required: true }), 'Enter')).toBe('Enter Field Label *');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('skips empty label', () => {
|
|
36
|
+
expect(subject(buildField({ label: '' }), 'Enter')).toBe('Enter');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('returns required asterisk only when label is empty and required', () => {
|
|
40
|
+
expect(subject(buildField({ label: '', required: true }))).toBe('*');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('returns empty string when label is empty, not required, and no prefix', () => {
|
|
44
|
+
expect(subject(buildField({ label: '' }))).toBe('');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { escapeHtml } from '../dom.utils';
|
|
2
|
+
|
|
3
|
+
describe('escapeHtml', () => {
|
|
4
|
+
const subject = (value: string) => escapeHtml(value);
|
|
5
|
+
|
|
6
|
+
test.each([
|
|
7
|
+
['<', '<'],
|
|
8
|
+
['>', '>'],
|
|
9
|
+
['&', '&'],
|
|
10
|
+
['"', '"'],
|
|
11
|
+
["'", '''],
|
|
12
|
+
])('escapes %s to %s', (value, expected) => {
|
|
13
|
+
expect(subject(value)).toBe(expected);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('escapes a typical HTML snippet', () => {
|
|
17
|
+
expect(subject('<div class="x">A & B</div>')).toBe(
|
|
18
|
+
'<div class="x">A & B</div>',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('returns plain string unchanged', () => {
|
|
23
|
+
expect(subject('Hello world')).toBe('Hello world');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('returns empty string for empty input', () => {
|
|
27
|
+
expect(subject('')).toBe('');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('escapes ampersand first to avoid double encoding', () => {
|
|
31
|
+
expect(subject('<')).toBe('&lt;');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { DataModelValues, StructuredFormula } from '../../../interface/types';
|
|
2
|
+
import { evaluateFormula, valueToNumber } from '../evaluate-formula.utils';
|
|
3
|
+
|
|
4
|
+
const f = (tokens: StructuredFormula['tokens']): StructuredFormula => ({ tokens });
|
|
5
|
+
|
|
6
|
+
describe('valueToNumber', () => {
|
|
7
|
+
const subject = (raw: unknown) => valueToNumber(raw);
|
|
8
|
+
|
|
9
|
+
test.each([
|
|
10
|
+
['null', null, 0],
|
|
11
|
+
['undefined', undefined, 0],
|
|
12
|
+
['empty string', '', 0],
|
|
13
|
+
['whitespace', ' ', 0],
|
|
14
|
+
['NaN', NaN, 0],
|
|
15
|
+
['non-numeric string', 'abc', 0],
|
|
16
|
+
['boolean', true, 0],
|
|
17
|
+
])('returns 0 for %s', (_, raw, expected) => {
|
|
18
|
+
expect(subject(raw)).toBe(expected);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test.each([
|
|
22
|
+
['integer', 42, 42],
|
|
23
|
+
['negative integer', -5, -5],
|
|
24
|
+
['float', 3.14, 3.14],
|
|
25
|
+
['integer string', '42', 42],
|
|
26
|
+
['float string', '3.14', 3.14],
|
|
27
|
+
['string with whitespace', ' 42 ', 42],
|
|
28
|
+
])('returns numeric value for %s', (_, raw, expected) => {
|
|
29
|
+
expect(subject(raw)).toBe(expected);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('evaluateFormula', () => {
|
|
34
|
+
const subject = (
|
|
35
|
+
formula: StructuredFormula | undefined | null,
|
|
36
|
+
data: DataModelValues | undefined,
|
|
37
|
+
holidays?: string[],
|
|
38
|
+
) => evaluateFormula(formula, data, holidays);
|
|
39
|
+
|
|
40
|
+
describe('when formula is empty', () => {
|
|
41
|
+
test.each([
|
|
42
|
+
['undefined', undefined],
|
|
43
|
+
['null', null],
|
|
44
|
+
['empty tokens', f([])],
|
|
45
|
+
])('returns null for %s', (_, formula) => {
|
|
46
|
+
expect(subject(formula, {})).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('with numeric arithmetic', () => {
|
|
51
|
+
test.each([
|
|
52
|
+
[
|
|
53
|
+
'addition',
|
|
54
|
+
f([
|
|
55
|
+
{ type: 'number', value: '1' },
|
|
56
|
+
{ type: 'operator', value: '+' },
|
|
57
|
+
{ type: 'number', value: '2' },
|
|
58
|
+
]),
|
|
59
|
+
3,
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
'subtraction',
|
|
63
|
+
f([
|
|
64
|
+
{ type: 'number', value: '10' },
|
|
65
|
+
{ type: 'operator', value: '-' },
|
|
66
|
+
{ type: 'number', value: '3' },
|
|
67
|
+
]),
|
|
68
|
+
7,
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
'multiplication',
|
|
72
|
+
f([
|
|
73
|
+
{ type: 'number', value: '4' },
|
|
74
|
+
{ type: 'operator', value: '*' },
|
|
75
|
+
{ type: 'number', value: '5' },
|
|
76
|
+
]),
|
|
77
|
+
20,
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
'division',
|
|
81
|
+
f([
|
|
82
|
+
{ type: 'number', value: '20' },
|
|
83
|
+
{ type: 'operator', value: '/' },
|
|
84
|
+
{ type: 'number', value: '4' },
|
|
85
|
+
]),
|
|
86
|
+
5,
|
|
87
|
+
],
|
|
88
|
+
])('evaluates %s', (_, formula, expected) => {
|
|
89
|
+
expect(subject(formula, {})).toBe(expected);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('returns 0 for division by zero', () => {
|
|
93
|
+
const formula = f([
|
|
94
|
+
{ type: 'number', value: '10' },
|
|
95
|
+
{ type: 'operator', value: '/' },
|
|
96
|
+
{ type: 'number', value: '0' },
|
|
97
|
+
]);
|
|
98
|
+
expect(subject(formula, {})).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('honors operator precedence (multiply before add)', () => {
|
|
102
|
+
const formula = f([
|
|
103
|
+
{ type: 'number', value: '1' },
|
|
104
|
+
{ type: 'operator', value: '+' },
|
|
105
|
+
{ type: 'number', value: '2' },
|
|
106
|
+
{ type: 'operator', value: '*' },
|
|
107
|
+
{ type: 'number', value: '3' },
|
|
108
|
+
]);
|
|
109
|
+
expect(subject(formula, {})).toBe(7);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('respects parentheses', () => {
|
|
113
|
+
const formula = f([
|
|
114
|
+
{ type: 'lparen' },
|
|
115
|
+
{ type: 'number', value: '1' },
|
|
116
|
+
{ type: 'operator', value: '+' },
|
|
117
|
+
{ type: 'number', value: '2' },
|
|
118
|
+
{ type: 'rparen' },
|
|
119
|
+
{ type: 'operator', value: '*' },
|
|
120
|
+
{ type: 'number', value: '3' },
|
|
121
|
+
]);
|
|
122
|
+
expect(subject(formula, {})).toBe(9);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('with field references', () => {
|
|
127
|
+
test('resolves field values from data', () => {
|
|
128
|
+
const formula = f([
|
|
129
|
+
{ type: 'field', path: 'a', fieldType: 'number' },
|
|
130
|
+
{ type: 'operator', value: '+' },
|
|
131
|
+
{ type: 'field', path: 'b', fieldType: 'number' },
|
|
132
|
+
]);
|
|
133
|
+
expect(subject(formula, { a: 5, b: 7 })).toBe(12);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('returns null when any field value is missing', () => {
|
|
137
|
+
const formula = f([
|
|
138
|
+
{ type: 'field', path: 'a', fieldType: 'number' },
|
|
139
|
+
{ type: 'operator', value: '+' },
|
|
140
|
+
{ type: 'field', path: 'b', fieldType: 'number' },
|
|
141
|
+
]);
|
|
142
|
+
expect(subject(formula, { a: 5 })).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('returns null when a field value is not numeric', () => {
|
|
146
|
+
const formula = f([
|
|
147
|
+
{ type: 'field', path: 'a', fieldType: 'number' },
|
|
148
|
+
{ type: 'operator', value: '+' },
|
|
149
|
+
{ type: 'number', value: '1' },
|
|
150
|
+
]);
|
|
151
|
+
expect(subject(formula, { a: 'not a number' })).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('with date fields', () => {
|
|
156
|
+
test('adds business days to a date field (skipping weekend)', () => {
|
|
157
|
+
// Friday 2024-01-05 + 1 business day = Monday 2024-01-08
|
|
158
|
+
const formula = f([
|
|
159
|
+
{ type: 'field', path: 'd', fieldType: 'date' },
|
|
160
|
+
{ type: 'operator', value: '+' },
|
|
161
|
+
{ type: 'number', value: '1' },
|
|
162
|
+
]);
|
|
163
|
+
const result = subject(formula, { d: '2024-01-05' });
|
|
164
|
+
expect(result).not.toBeNull();
|
|
165
|
+
const date = new Date(result as number);
|
|
166
|
+
expect(date.getUTCFullYear()).toBe(2024);
|
|
167
|
+
expect(date.getUTCMonth()).toBe(0);
|
|
168
|
+
expect(date.getUTCDate()).toBe(8);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('counts business days when subtracting later date from earlier date', () => {
|
|
172
|
+
// start - end traverses Mon 2024-01-01 → Fri 2024-01-05 = 4 business days
|
|
173
|
+
const formula = f([
|
|
174
|
+
{ type: 'field', path: 'start', fieldType: 'date' },
|
|
175
|
+
{ type: 'operator', value: '-' },
|
|
176
|
+
{ type: 'field', path: 'end', fieldType: 'date' },
|
|
177
|
+
]);
|
|
178
|
+
expect(subject(formula, { start: '2024-01-01', end: '2024-01-05' })).toBe(4);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('skips holidays when adding business days', () => {
|
|
182
|
+
// Monday 2024-01-08 + 1 day skips holiday 2024-01-09 = Wednesday 2024-01-10
|
|
183
|
+
const formula = f([
|
|
184
|
+
{ type: 'field', path: 'd', fieldType: 'date' },
|
|
185
|
+
{ type: 'operator', value: '+' },
|
|
186
|
+
{ type: 'number', value: '1' },
|
|
187
|
+
]);
|
|
188
|
+
const result = subject(formula, { d: '2024-01-08' }, ['2024-01-09']);
|
|
189
|
+
expect(result).not.toBeNull();
|
|
190
|
+
expect(new Date(result as number).getUTCDate()).toBe(10);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('returns null when date field has invalid value', () => {
|
|
194
|
+
const formula = f([
|
|
195
|
+
{ type: 'field', path: 'd', fieldType: 'date' },
|
|
196
|
+
{ type: 'operator', value: '+' },
|
|
197
|
+
{ type: 'number', value: '1' },
|
|
198
|
+
]);
|
|
199
|
+
expect(subject(formula, { d: 'not-a-date' })).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { StructuredFormula } from '../../../interface/types';
|
|
2
|
+
import {
|
|
3
|
+
normalizeMergeTags,
|
|
4
|
+
parseExpression,
|
|
5
|
+
tokenizeExpression,
|
|
6
|
+
tokensToExpression,
|
|
7
|
+
} from '../expression.utils';
|
|
8
|
+
|
|
9
|
+
describe('tokensToExpression', () => {
|
|
10
|
+
const subject = (formula: StructuredFormula | undefined) => tokensToExpression(formula);
|
|
11
|
+
|
|
12
|
+
test('returns empty string when formula is undefined', () => {
|
|
13
|
+
expect(subject(undefined)).toBe('');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('returns empty string when tokens array is empty', () => {
|
|
17
|
+
expect(subject({ tokens: [] })).toBe('');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('serializes a mix of token types separated by spaces', () => {
|
|
21
|
+
const formula: StructuredFormula = {
|
|
22
|
+
tokens: [
|
|
23
|
+
{ type: 'lparen' },
|
|
24
|
+
{ type: 'field', path: 'a.b', fieldType: 'number' },
|
|
25
|
+
{ type: 'operator', value: '+' },
|
|
26
|
+
{ type: 'number', value: '5' },
|
|
27
|
+
{ type: 'rparen' },
|
|
28
|
+
{ type: 'operator', value: '*' },
|
|
29
|
+
{ type: 'number', value: '2' },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
expect(subject(formula)).toBe('( a.b + 5 ) * 2');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('tokenizeExpression', () => {
|
|
37
|
+
const subject = (expression: string) => tokenizeExpression(expression);
|
|
38
|
+
|
|
39
|
+
test('returns empty array for empty input', () => {
|
|
40
|
+
expect(subject('')).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('tokenizes identifiers with dots and hyphens as fields', () => {
|
|
44
|
+
expect(subject('user.name-id')).toEqual([{ type: 'field', value: 'user.name-id' }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('tokenizes numbers including decimals', () => {
|
|
48
|
+
expect(subject('3.14 42')).toEqual([
|
|
49
|
+
{ type: 'number', value: '3.14' },
|
|
50
|
+
{ type: 'text', value: ' ' },
|
|
51
|
+
{ type: 'number', value: '42' },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('tokenizes operators and parens', () => {
|
|
56
|
+
const result = subject('(a + b)');
|
|
57
|
+
expect(result.filter(t => t.type === 'paren').map(t => t.value)).toEqual(['(', ')']);
|
|
58
|
+
expect(result.filter(t => t.type === 'operator').map(t => t.value)).toEqual(['+']);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('parseExpression', () => {
|
|
63
|
+
const subject = (
|
|
64
|
+
expression: string,
|
|
65
|
+
validPaths: Set<string>,
|
|
66
|
+
knownDateFields?: Set<string>,
|
|
67
|
+
) => parseExpression(expression, validPaths, knownDateFields);
|
|
68
|
+
|
|
69
|
+
test('drops unknown field identifiers', () => {
|
|
70
|
+
const validPaths = new Set(['known.path']);
|
|
71
|
+
const tokens = subject('known.path + unknown + 1', validPaths);
|
|
72
|
+
expect(tokens).toEqual([
|
|
73
|
+
{ type: 'field', path: 'known.path', fieldType: 'number' },
|
|
74
|
+
{ type: 'operator', value: '+' },
|
|
75
|
+
{ type: 'operator', value: '+' },
|
|
76
|
+
{ type: 'number', value: '1' },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('marks known date fields with fieldType date', () => {
|
|
81
|
+
const tokens = subject(
|
|
82
|
+
'a.date + 1',
|
|
83
|
+
new Set(['a.date']),
|
|
84
|
+
new Set(['a.date']),
|
|
85
|
+
);
|
|
86
|
+
expect(tokens[0]).toEqual({ type: 'field', path: 'a.date', fieldType: 'date' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('parses parentheses to lparen/rparen tokens', () => {
|
|
90
|
+
const tokens = subject('( 1 + 2 )', new Set());
|
|
91
|
+
expect(tokens.map(t => t.type)).toEqual([
|
|
92
|
+
'lparen',
|
|
93
|
+
'number',
|
|
94
|
+
'operator',
|
|
95
|
+
'number',
|
|
96
|
+
'rparen',
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('normalizeMergeTags', () => {
|
|
102
|
+
const subject = (expression: string, validPaths: Set<string>) =>
|
|
103
|
+
normalizeMergeTags(expression, validPaths);
|
|
104
|
+
|
|
105
|
+
test('returns input unchanged when validPaths is empty', () => {
|
|
106
|
+
expect(subject('a b', new Set())).toEqual({ normalized: 'a b', removed: null });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('returns input unchanged when no adjacent merge tags exist', () => {
|
|
110
|
+
const result = subject('a + b', new Set(['a', 'b']));
|
|
111
|
+
expect(result).toEqual({ normalized: 'a + b', removed: null });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('removes the left tag when two valid tags are adjacent without separator', () => {
|
|
115
|
+
const result = subject('ab', new Set(['a', 'b']));
|
|
116
|
+
expect(result.normalized).toBe('b');
|
|
117
|
+
expect(result.removed).toEqual({ start: 0, end: 1 });
|
|
118
|
+
});
|
|
119
|
+
});
|