@cccsaurora/howler-ui 2.19.0-dev.938 → 2.19.0-dev.950
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/package.json +1 -1
- package/setupTests.js +5 -0
- package/utils/actionUtils.test.d.ts +1 -0
- package/utils/actionUtils.test.js +140 -0
- package/utils/stringUtils.test.d.ts +1 -0
- package/utils/stringUtils.test.js +159 -0
- package/utils/utils.test.d.ts +1 -0
- package/utils/utils.test.js +292 -0
- package/utils/viewUtils.test.d.ts +1 -0
- package/utils/viewUtils.test.js +45 -0
package/package.json
CHANGED
package/setupTests.js
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
import * as matchers from '@testing-library/jest-dom/matchers';
|
|
3
3
|
import '@testing-library/jest-dom/vitest';
|
|
4
4
|
import { configure } from '@testing-library/react';
|
|
5
|
+
import dayjs from 'dayjs';
|
|
6
|
+
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
7
|
+
import utc from 'dayjs/plugin/utc';
|
|
5
8
|
import { server } from '@cccsaurora/howler-ui/tests/server';
|
|
9
|
+
dayjs.extend(utc);
|
|
10
|
+
dayjs.extend(relativeTime);
|
|
6
11
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
7
12
|
// Extend vitest with the dom matchers from jest-dom.
|
|
8
13
|
expect.extend(matchers);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { checkArgsAreFilled, getArgsByContext, getOptionsByContext, operationReady } from './actionUtils';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
const makeStep = (args, options = {}) => ({
|
|
7
|
+
args,
|
|
8
|
+
options,
|
|
9
|
+
validation: {}
|
|
10
|
+
});
|
|
11
|
+
const makeAction = (steps) => ({
|
|
12
|
+
id: 'test-action',
|
|
13
|
+
title: 'Test Action',
|
|
14
|
+
i18nKey: 'test.action',
|
|
15
|
+
description: { short: '', long: '' },
|
|
16
|
+
roles: [],
|
|
17
|
+
steps,
|
|
18
|
+
triggers: []
|
|
19
|
+
});
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// checkArgsAreFilled
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
describe('checkArgsAreFilled', () => {
|
|
24
|
+
it('returns false when values is empty/falsy', () => {
|
|
25
|
+
const step = makeStep({ status: [] });
|
|
26
|
+
expect(checkArgsAreFilled(step, '')).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
it('returns true when all required args are present and truthy', () => {
|
|
29
|
+
const step = makeStep({ status: [] });
|
|
30
|
+
expect(checkArgsAreFilled(step, JSON.stringify({ status: 'open' }))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
it('returns false when a required arg is missing from values', () => {
|
|
33
|
+
const step = makeStep({ status: [], assignment: [] });
|
|
34
|
+
expect(checkArgsAreFilled(step, JSON.stringify({ status: 'open' }))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
it('returns false when a required arg is present but falsy (empty string)', () => {
|
|
37
|
+
const step = makeStep({ status: [] });
|
|
38
|
+
expect(checkArgsAreFilled(step, JSON.stringify({ status: '' }))).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
it('returns true when there are no required args', () => {
|
|
41
|
+
const step = makeStep({});
|
|
42
|
+
expect(checkArgsAreFilled(step, JSON.stringify({}))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// getOptionsByContext
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
describe('getOptionsByContext', () => {
|
|
49
|
+
it('returns an empty array when the arg is not in options', () => {
|
|
50
|
+
const options = {};
|
|
51
|
+
expect(getOptionsByContext(options, 'status', '{}')).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
it('returns the full list when the arg maps to a plain array', () => {
|
|
54
|
+
const options = { status: ['open', 'in-progress', 'resolved'] };
|
|
55
|
+
expect(getOptionsByContext(options, 'status', '{}')).toEqual(['open', 'in-progress', 'resolved']);
|
|
56
|
+
});
|
|
57
|
+
it('returns options matching the current context when arg has conditional options', () => {
|
|
58
|
+
const options = {
|
|
59
|
+
assessment: {
|
|
60
|
+
'status:open': ['false_positive', 'legitimate'],
|
|
61
|
+
'status:resolved': ['correct']
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const context = JSON.stringify({ status: 'open' });
|
|
65
|
+
expect(getOptionsByContext(options, 'assessment', context)).toEqual(['false_positive', 'legitimate']);
|
|
66
|
+
});
|
|
67
|
+
it('returns an empty array when context does not match any conditional key', () => {
|
|
68
|
+
const options = {
|
|
69
|
+
assessment: {
|
|
70
|
+
'status:open': ['false_positive']
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const context = JSON.stringify({ status: 'resolved' });
|
|
74
|
+
expect(getOptionsByContext(options, 'assessment', context)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// getArgsByContext
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
describe('getArgsByContext', () => {
|
|
81
|
+
it('always returns an arg with an empty conditions array', () => {
|
|
82
|
+
const args = { status: [] };
|
|
83
|
+
expect(getArgsByContext(args, '{}')).toContain('status');
|
|
84
|
+
});
|
|
85
|
+
it('includes an arg whose condition matches the current context', () => {
|
|
86
|
+
const args = { assessment: ['status:open'] };
|
|
87
|
+
const values = JSON.stringify({ status: 'open' });
|
|
88
|
+
expect(getArgsByContext(args, values)).toContain('assessment');
|
|
89
|
+
});
|
|
90
|
+
it('excludes an arg whose condition does not match the current context', () => {
|
|
91
|
+
const args = { assessment: ['status:open'] };
|
|
92
|
+
const values = JSON.stringify({ status: 'resolved' });
|
|
93
|
+
expect(getArgsByContext(args, values)).not.toContain('assessment');
|
|
94
|
+
});
|
|
95
|
+
it('includes an arg when any one of multiple conditions matches (OR logic)', () => {
|
|
96
|
+
const args = { rationale: ['status:open', 'status:in-progress'] };
|
|
97
|
+
const values = JSON.stringify({ status: 'in-progress' });
|
|
98
|
+
expect(getArgsByContext(args, values)).toContain('rationale');
|
|
99
|
+
});
|
|
100
|
+
it('returns an empty array when no args match', () => {
|
|
101
|
+
const args = { assessment: ['status:open'] };
|
|
102
|
+
const values = JSON.stringify({ status: 'resolved' });
|
|
103
|
+
expect(getArgsByContext(args, values)).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// operationReady
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
describe('operationReady', () => {
|
|
110
|
+
it('returns false when data is null/falsy', () => {
|
|
111
|
+
const action = makeAction([makeStep({ status: [] })]);
|
|
112
|
+
expect(operationReady(null, action)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
it('returns falsy when action is falsy', () => {
|
|
115
|
+
expect(operationReady(JSON.stringify({ status: 'open' }), null)).toBeFalsy();
|
|
116
|
+
});
|
|
117
|
+
it('returns true when all unconditional args are present', () => {
|
|
118
|
+
const action = makeAction([makeStep({ status: [] })]);
|
|
119
|
+
const data = JSON.stringify({ status: 'open' });
|
|
120
|
+
expect(operationReady(data, action)).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
it('returns false when a required unconditional arg is missing', () => {
|
|
123
|
+
const action = makeAction([makeStep({ status: [], assessment: [] })]);
|
|
124
|
+
const data = JSON.stringify({ status: 'open' });
|
|
125
|
+
expect(operationReady(data, action)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
it('ignores conditional args that do not apply in the current context', () => {
|
|
128
|
+
// assessment only required when status=open; here status=resolved so assessment is not required
|
|
129
|
+
const action = makeAction([makeStep({ status: [], assessment: ['status:open'] })]);
|
|
130
|
+
const data = JSON.stringify({ status: 'resolved' });
|
|
131
|
+
expect(operationReady(data, action)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
it('merges args across multiple steps', () => {
|
|
134
|
+
const step1 = makeStep({ status: [] });
|
|
135
|
+
const step2 = makeStep({ assignment: [] });
|
|
136
|
+
const action = makeAction([step1, step2]);
|
|
137
|
+
const data = JSON.stringify({ status: 'open', assignment: 'alice' });
|
|
138
|
+
expect(operationReady(data, action)).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { maxLenStr, nameToInitials, safeFieldValue, safeFieldValueURI, safeStringPropertyCompare, sanitizeLuceneQuery, sanitizeMultilineLucene, validateRegex } from './stringUtils';
|
|
4
|
+
describe('nameToInitials', () => {
|
|
5
|
+
it('returns initials from a standard first-last name', () => {
|
|
6
|
+
expect(nameToInitials('John Doe')).toEqual(['J', 'D']);
|
|
7
|
+
});
|
|
8
|
+
it('returns a single initial when there is only one word', () => {
|
|
9
|
+
expect(nameToInitials('John')).toEqual(['J']);
|
|
10
|
+
});
|
|
11
|
+
it('uses only the first two words even when there are more', () => {
|
|
12
|
+
expect(nameToInitials('John Middle Doe')).toEqual(['J', 'M']);
|
|
13
|
+
});
|
|
14
|
+
it('reverses order when the name is in "last, first" comma format', () => {
|
|
15
|
+
// "Smith, John" → parts = ["Smith,", "John"] → reversed → ["John", "Smith,"] → initials ["J", "S"]
|
|
16
|
+
expect(nameToInitials('Smith, John')).toEqual(['J', 'S']);
|
|
17
|
+
});
|
|
18
|
+
it('returns uppercase initials regardless of input case', () => {
|
|
19
|
+
expect(nameToInitials('alice bob')).toEqual(['A', 'B']);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('maxLenStr', () => {
|
|
23
|
+
it('returns the string unchanged when shorter than the limit', () => {
|
|
24
|
+
expect(maxLenStr('hello', 10)).toBe('hello');
|
|
25
|
+
});
|
|
26
|
+
it('returns the string unchanged when length equals the limit', () => {
|
|
27
|
+
expect(maxLenStr('hello', 5)).toBe('hello');
|
|
28
|
+
});
|
|
29
|
+
it('truncates and appends "..." when string exceeds the limit', () => {
|
|
30
|
+
expect(maxLenStr('hello world', 8)).toBe('hello...');
|
|
31
|
+
});
|
|
32
|
+
it('handles an empty string', () => {
|
|
33
|
+
expect(maxLenStr('', 5)).toBe('');
|
|
34
|
+
});
|
|
35
|
+
it('truncation accounts for the three-character ellipsis', () => {
|
|
36
|
+
// len=6 → keeps first 3 chars then "..."
|
|
37
|
+
expect(maxLenStr('abcdefgh', 6)).toBe('abc...');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('safeFieldValue', () => {
|
|
41
|
+
it('wraps a plain string in double quotes', () => {
|
|
42
|
+
expect(safeFieldValue('test')).toBe('"test"');
|
|
43
|
+
});
|
|
44
|
+
it('escapes backslashes', () => {
|
|
45
|
+
expect(safeFieldValue('back\\slash')).toBe('"back\\\\slash"');
|
|
46
|
+
});
|
|
47
|
+
it('escapes embedded double quotes', () => {
|
|
48
|
+
expect(safeFieldValue('say "hello"')).toBe('"say \\"hello\\""');
|
|
49
|
+
});
|
|
50
|
+
it('converts a number to a quoted string', () => {
|
|
51
|
+
expect(safeFieldValue(42)).toBe('"42"');
|
|
52
|
+
});
|
|
53
|
+
it('converts a boolean to a quoted string', () => {
|
|
54
|
+
expect(safeFieldValue(true)).toBe('"true"');
|
|
55
|
+
});
|
|
56
|
+
it('escapes backslash before escaping quotes (order matters)', () => {
|
|
57
|
+
expect(safeFieldValue('\\"')).toBe('"\\\\\\"\"');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('safeFieldValueURI', () => {
|
|
61
|
+
it('URI-encodes the safe field value of a plain string', () => {
|
|
62
|
+
expect(safeFieldValueURI('hello')).toBe(encodeURIComponent('"hello"'));
|
|
63
|
+
});
|
|
64
|
+
it('URI-encodes special characters', () => {
|
|
65
|
+
expect(safeFieldValueURI('hello world')).toBe(encodeURIComponent('"hello world"'));
|
|
66
|
+
});
|
|
67
|
+
it('handles strings that already contain lucene special chars', () => {
|
|
68
|
+
const input = 'field:value';
|
|
69
|
+
expect(safeFieldValueURI(input)).toBe(encodeURIComponent(safeFieldValue(input)));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('sanitizeLuceneQuery', () => {
|
|
73
|
+
it('escapes colons', () => {
|
|
74
|
+
expect(sanitizeLuceneQuery('field:value')).toBe('field\\:value');
|
|
75
|
+
});
|
|
76
|
+
it('escapes forward slashes', () => {
|
|
77
|
+
expect(sanitizeLuceneQuery('path/to')).toBe('path\\/to');
|
|
78
|
+
});
|
|
79
|
+
it('escapes opening parenthesis', () => {
|
|
80
|
+
expect(sanitizeLuceneQuery('(term)')).toBe('\\(term\\)');
|
|
81
|
+
});
|
|
82
|
+
it('escapes square brackets', () => {
|
|
83
|
+
expect(sanitizeLuceneQuery('[a TO b]')).toBe('\\[a TO b\\]');
|
|
84
|
+
});
|
|
85
|
+
it('escapes curly braces', () => {
|
|
86
|
+
expect(sanitizeLuceneQuery('{a TO b}')).toBe('\\{a TO b\\}');
|
|
87
|
+
});
|
|
88
|
+
it('escapes carets', () => {
|
|
89
|
+
expect(sanitizeLuceneQuery('term^2')).toBe('term\\^2');
|
|
90
|
+
});
|
|
91
|
+
it('escapes double-ampersand (&&)', () => {
|
|
92
|
+
expect(sanitizeLuceneQuery('a && b')).toBe('a \\&& b');
|
|
93
|
+
});
|
|
94
|
+
it('escapes double-pipe (||)', () => {
|
|
95
|
+
expect(sanitizeLuceneQuery('a || b')).toBe('a \\|| b');
|
|
96
|
+
});
|
|
97
|
+
it('leaves a plain alphanumeric string unchanged', () => {
|
|
98
|
+
expect(sanitizeLuceneQuery('plainterm')).toBe('plainterm');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe('safeStringPropertyCompare', () => {
|
|
102
|
+
const compare = safeStringPropertyCompare('name');
|
|
103
|
+
it('returns a negative number when a sorts before b', () => {
|
|
104
|
+
expect(compare({ name: 'Alice' }, { name: 'Bob' })).toBeLessThan(0);
|
|
105
|
+
});
|
|
106
|
+
it('returns a positive number when a sorts after b', () => {
|
|
107
|
+
expect(compare({ name: 'Bob' }, { name: 'Alice' })).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
it('returns 0 for equal strings', () => {
|
|
110
|
+
expect(compare({ name: 'Alice' }, { name: 'Alice' })).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
it('returns 1 when only a has the property', () => {
|
|
113
|
+
expect(compare({ name: 'Alice' }, {})).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
it('returns 0 when only b has the property', () => {
|
|
116
|
+
expect(compare({}, { name: 'Bob' })).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
it('returns 0 when neither object has the property', () => {
|
|
119
|
+
expect(compare({}, {})).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
it('supports nested property paths', () => {
|
|
122
|
+
const nestedCompare = safeStringPropertyCompare('user.name');
|
|
123
|
+
expect(nestedCompare({ user: { name: 'Alice' } }, { user: { name: 'Bob' } })).toBeLessThan(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('sanitizeMultilineLucene', () => {
|
|
127
|
+
it('removes a trailing inline comment', () => {
|
|
128
|
+
expect(sanitizeMultilineLucene('query # comment')).toBe('query ');
|
|
129
|
+
});
|
|
130
|
+
it('removes a full-line comment', () => {
|
|
131
|
+
expect(sanitizeMultilineLucene('# full line\nquery')).toBe('\nquery');
|
|
132
|
+
});
|
|
133
|
+
it('collapses two or more consecutive newlines into one', () => {
|
|
134
|
+
expect(sanitizeMultilineLucene('a\n\n\nb')).toBe('a\nb');
|
|
135
|
+
});
|
|
136
|
+
it('collapses exactly two consecutive newlines', () => {
|
|
137
|
+
expect(sanitizeMultilineLucene('a\n\nb')).toBe('a\nb');
|
|
138
|
+
});
|
|
139
|
+
it('leaves a clean single-line query unchanged', () => {
|
|
140
|
+
expect(sanitizeMultilineLucene('howler.status:open')).toBe('howler.status:open');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('validateRegex', () => {
|
|
144
|
+
it('returns true for a valid regex pattern', () => {
|
|
145
|
+
expect(validateRegex('[a-z]+')).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
it('returns true for an empty string (valid zero-length pattern)', () => {
|
|
148
|
+
expect(validateRegex('')).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
it('returns false for an invalid regex pattern', () => {
|
|
151
|
+
expect(validateRegex('[unclosed')).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
it('returns true for a complex but valid regex', () => {
|
|
154
|
+
expect(validateRegex('^(\\d{4})-(\\d{2})-(\\d{2})$')).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
it('returns true for a pattern with quantifiers', () => {
|
|
157
|
+
expect(validateRegex('a{2,5}')).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { bytesToSize, compareTimestamp, convertCustomDateRangeToLucene, convertDateToLucene, convertLuceneToDate, flattenDeep, formatDate, getTimeRange, hashCode, humanReadableNumber, removeEmpty, searchObject, sortByTimestamp, twitterShort, tryParse } from './utils';
|
|
4
|
+
describe('bytesToSize', () => {
|
|
5
|
+
it('returns "0 B" for 0', () => {
|
|
6
|
+
expect(bytesToSize(0)).toBe('0 B');
|
|
7
|
+
});
|
|
8
|
+
it('returns "0 B" for null', () => {
|
|
9
|
+
expect(bytesToSize(null)).toBe('0 B');
|
|
10
|
+
});
|
|
11
|
+
it('formats bytes', () => {
|
|
12
|
+
expect(bytesToSize(512)).toBe('512 B');
|
|
13
|
+
});
|
|
14
|
+
it('formats kilobytes', () => {
|
|
15
|
+
expect(bytesToSize(1024)).toBe('1 KB');
|
|
16
|
+
});
|
|
17
|
+
it('formats megabytes', () => {
|
|
18
|
+
expect(bytesToSize(1024 * 1024)).toBe('1 MB');
|
|
19
|
+
});
|
|
20
|
+
it('formats gigabytes', () => {
|
|
21
|
+
expect(bytesToSize(1024 * 1024 * 1024)).toBe('1 GB');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('humanReadableNumber', () => {
|
|
25
|
+
it('returns "0 " for 0', () => {
|
|
26
|
+
expect(humanReadableNumber(0)).toBe('0 ');
|
|
27
|
+
});
|
|
28
|
+
it('returns "0 " for null', () => {
|
|
29
|
+
expect(humanReadableNumber(null)).toBe('0 ');
|
|
30
|
+
});
|
|
31
|
+
it('returns the number with a trailing space for values below 1000', () => {
|
|
32
|
+
expect(humanReadableNumber(500)).toBe('500 ');
|
|
33
|
+
});
|
|
34
|
+
it('formats thousands with "k"', () => {
|
|
35
|
+
expect(humanReadableNumber(1000)).toBe('1k ');
|
|
36
|
+
});
|
|
37
|
+
it('formats millions with "m"', () => {
|
|
38
|
+
expect(humanReadableNumber(1_000_000)).toBe('1m ');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('compareTimestamp', () => {
|
|
42
|
+
it('returns a negative number when a is earlier than b', () => {
|
|
43
|
+
expect(compareTimestamp('2021-01-01T00:00:00Z', '2021-01-02T00:00:00Z')).toBeLessThan(0);
|
|
44
|
+
});
|
|
45
|
+
it('returns a positive number when a is later than b', () => {
|
|
46
|
+
expect(compareTimestamp('2021-01-02T00:00:00Z', '2021-01-01T00:00:00Z')).toBeGreaterThan(0);
|
|
47
|
+
});
|
|
48
|
+
it('returns 0 for identical timestamps', () => {
|
|
49
|
+
expect(compareTimestamp('2021-01-01T00:00:00Z', '2021-01-01T00:00:00Z')).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
it('returns a difference in seconds', () => {
|
|
52
|
+
// 86400 seconds = 1 day
|
|
53
|
+
expect(compareTimestamp('2021-01-02T00:00:00Z', '2021-01-01T00:00:00Z')).toBe(86400);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('hashCode', () => {
|
|
57
|
+
it('returns a number', () => {
|
|
58
|
+
expect(typeof hashCode('hello')).toBe('number');
|
|
59
|
+
});
|
|
60
|
+
it('returns the same value for the same input', () => {
|
|
61
|
+
expect(hashCode('hello')).toBe(hashCode('hello'));
|
|
62
|
+
});
|
|
63
|
+
it('returns different values for different inputs', () => {
|
|
64
|
+
expect(hashCode('hello')).not.toBe(hashCode('world'));
|
|
65
|
+
});
|
|
66
|
+
it('returns 0 for an empty string', () => {
|
|
67
|
+
expect(hashCode('')).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('sortByTimestamp', () => {
|
|
71
|
+
it('returns an empty array for an empty input', () => {
|
|
72
|
+
expect(sortByTimestamp([])).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
it('sorts items in descending timestamp order (most recent first)', () => {
|
|
75
|
+
const items = [
|
|
76
|
+
{ timestamp: '2021-01-01T00:00:00Z' },
|
|
77
|
+
{ timestamp: '2021-03-01T00:00:00Z' },
|
|
78
|
+
{ timestamp: '2021-02-01T00:00:00Z' }
|
|
79
|
+
];
|
|
80
|
+
const sorted = sortByTimestamp(items);
|
|
81
|
+
expect(sorted[0].timestamp).toBe('2021-03-01T00:00:00Z');
|
|
82
|
+
expect(sorted[2].timestamp).toBe('2021-01-01T00:00:00Z');
|
|
83
|
+
});
|
|
84
|
+
it('does not mutate the original array', () => {
|
|
85
|
+
const original = [{ timestamp: '2021-01-01T00:00:00Z' }, { timestamp: '2021-03-01T00:00:00Z' }];
|
|
86
|
+
const copy = [...original];
|
|
87
|
+
sortByTimestamp(original);
|
|
88
|
+
expect(original).toEqual(copy);
|
|
89
|
+
});
|
|
90
|
+
it('handles items with missing timestamps', () => {
|
|
91
|
+
const items = [{ timestamp: '2021-01-01T00:00:00Z' }, {}];
|
|
92
|
+
expect(() => sortByTimestamp(items)).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('getTimeRange', () => {
|
|
96
|
+
it('returns [earliest, latest] from an array of timestamps', () => {
|
|
97
|
+
const timestamps = ['2021-03-01T00:00:00Z', '2021-01-01T00:00:00Z', '2021-02-01T00:00:00Z'];
|
|
98
|
+
const [start, end] = getTimeRange(timestamps);
|
|
99
|
+
expect(start).toBe('2021-01-01T00:00:00Z');
|
|
100
|
+
expect(end).toBe('2021-03-01T00:00:00Z');
|
|
101
|
+
});
|
|
102
|
+
it('returns the same value for both when given a single timestamp', () => {
|
|
103
|
+
const [start, end] = getTimeRange(['2021-01-01T00:00:00Z']);
|
|
104
|
+
expect(start).toBe(end);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('removeEmpty', () => {
|
|
108
|
+
it('removes null values from a flat object', () => {
|
|
109
|
+
expect(removeEmpty({ a: null, b: 'val' })).toEqual({ b: 'val' });
|
|
110
|
+
});
|
|
111
|
+
it('removes undefined values from a flat object', () => {
|
|
112
|
+
expect(removeEmpty({ a: undefined, b: 'val' })).toEqual({ b: 'val' });
|
|
113
|
+
});
|
|
114
|
+
it('recursively removes null values from nested objects', () => {
|
|
115
|
+
expect(removeEmpty({ nested: { a: null, b: 'val' } })).toEqual({ nested: { b: 'val' } });
|
|
116
|
+
});
|
|
117
|
+
it('handles an empty object', () => {
|
|
118
|
+
expect(removeEmpty({})).toEqual({});
|
|
119
|
+
});
|
|
120
|
+
it('handles null input gracefully', () => {
|
|
121
|
+
expect(removeEmpty(null)).toEqual({});
|
|
122
|
+
});
|
|
123
|
+
it('keeps arrays as-is', () => {
|
|
124
|
+
const result = removeEmpty({ arr: [1, 2, 3] });
|
|
125
|
+
expect(result.arr).toEqual([1, 2, 3]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('searchObject', () => {
|
|
129
|
+
const obj = { name: 'Alice', role: 'admin', nested: { city: 'Ottawa' } };
|
|
130
|
+
it('returns the full object when query is empty', () => {
|
|
131
|
+
const result = searchObject(obj, '');
|
|
132
|
+
expect(result).toMatchObject(obj);
|
|
133
|
+
});
|
|
134
|
+
it('returns matching entries for a key match', () => {
|
|
135
|
+
const result = searchObject(obj, 'name');
|
|
136
|
+
expect(result.name).toBe('Alice');
|
|
137
|
+
});
|
|
138
|
+
it('returns matching entries for a value match', () => {
|
|
139
|
+
const result = searchObject(obj, 'Alice');
|
|
140
|
+
expect(result.name).toBe('Alice');
|
|
141
|
+
});
|
|
142
|
+
it('returns an empty object when nothing matches', () => {
|
|
143
|
+
const result = searchObject(obj, 'zzznomatch');
|
|
144
|
+
expect(result).toEqual({});
|
|
145
|
+
});
|
|
146
|
+
it('returns flat result when returnFlat=true', () => {
|
|
147
|
+
const result = searchObject(obj, 'city', true);
|
|
148
|
+
expect(result['nested.city']).toBe('Ottawa');
|
|
149
|
+
});
|
|
150
|
+
it('returns full flat object when query is empty and returnFlat=true', () => {
|
|
151
|
+
const result = searchObject({ a: 1 }, '', true);
|
|
152
|
+
expect(result.a).toBe(1);
|
|
153
|
+
});
|
|
154
|
+
it('handles an invalid regex gracefully by returning the full object', () => {
|
|
155
|
+
const result = searchObject(obj, '[invalid');
|
|
156
|
+
expect(result).toMatchObject(obj);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('convertDateToLucene', () => {
|
|
160
|
+
it('returns "[now-1d TO now]" for a 1-day range', () => {
|
|
161
|
+
expect(convertDateToLucene('date.range.1.day')).toBe('[now-1d TO now]');
|
|
162
|
+
});
|
|
163
|
+
it('returns "[now-1w TO now]" for a 1-week range', () => {
|
|
164
|
+
expect(convertDateToLucene('date.range.1.week')).toBe('[now-1w TO now]');
|
|
165
|
+
});
|
|
166
|
+
it('returns "[now-1M TO now]" for a 1-month range', () => {
|
|
167
|
+
expect(convertDateToLucene('date.range.1.month')).toBe('[now-1M TO now]');
|
|
168
|
+
});
|
|
169
|
+
it('returns "[now-1y TO now]" for a 1-year range', () => {
|
|
170
|
+
expect(convertDateToLucene('date.range.1.year')).toBe('[now-1y TO now]');
|
|
171
|
+
});
|
|
172
|
+
it('returns "*" for the "all" range', () => {
|
|
173
|
+
expect(convertDateToLucene('date.range.all')).toBe('*');
|
|
174
|
+
});
|
|
175
|
+
it('returns the default 1-day range when input does not start with "date.range."', () => {
|
|
176
|
+
expect(convertDateToLucene('something.else')).toBe('[now-1d TO now]');
|
|
177
|
+
});
|
|
178
|
+
it('uses the day unit as fallback for an unknown period type', () => {
|
|
179
|
+
expect(convertDateToLucene('date.range.3.unknown')).toBe('[now-3d TO now]');
|
|
180
|
+
});
|
|
181
|
+
it('handles multi-unit amounts', () => {
|
|
182
|
+
expect(convertDateToLucene('date.range.3.day')).toBe('[now-3d TO now]');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe('convertCustomDateRangeToLucene', () => {
|
|
186
|
+
it('formats a custom date range', () => {
|
|
187
|
+
expect(convertCustomDateRangeToLucene('2021-01-01', '2021-12-31')).toBe('[2021-01-01 TO 2021-12-31]');
|
|
188
|
+
});
|
|
189
|
+
it('works with ISO datetime strings', () => {
|
|
190
|
+
expect(convertCustomDateRangeToLucene('2021-01-01T00:00:00Z', '2021-12-31T23:59:59Z')).toBe('[2021-01-01T00:00:00Z TO 2021-12-31T23:59:59Z]');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('convertLuceneToDate', () => {
|
|
194
|
+
it('converts a 1-day lucene range back to "date.range.1.day"', () => {
|
|
195
|
+
expect(convertLuceneToDate('event.created:[now-1d TO now]')).toBe('date.range.1.day');
|
|
196
|
+
});
|
|
197
|
+
it('converts a 1-week lucene range back to "date.range.1.week"', () => {
|
|
198
|
+
expect(convertLuceneToDate('event.created:[now-1w TO now]')).toBe('date.range.1.week');
|
|
199
|
+
});
|
|
200
|
+
it('converts a 1-month lucene range back to "date.range.1.month"', () => {
|
|
201
|
+
expect(convertLuceneToDate('event.created:[now-1M TO now]')).toBe('date.range.1.month');
|
|
202
|
+
});
|
|
203
|
+
it('returns the input unchanged when there is no colon (not a field query)', () => {
|
|
204
|
+
expect(convertLuceneToDate('*')).toBe('*');
|
|
205
|
+
});
|
|
206
|
+
it('falls back to "day" for an unrecognised unit suffix', () => {
|
|
207
|
+
expect(convertLuceneToDate('event.created:[now-5z TO now]')).toBe('date.range.5.day');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('tryParse', () => {
|
|
211
|
+
it('parses valid JSON and returns the value', () => {
|
|
212
|
+
expect(tryParse('{"a":1}')).toEqual({ a: 1 });
|
|
213
|
+
});
|
|
214
|
+
it('parses a JSON array', () => {
|
|
215
|
+
expect(tryParse('[1,2,3]')).toEqual([1, 2, 3]);
|
|
216
|
+
});
|
|
217
|
+
it('returns the raw string when JSON is invalid', () => {
|
|
218
|
+
expect(tryParse('not json')).toBe('not json');
|
|
219
|
+
});
|
|
220
|
+
it('parses a quoted JSON string', () => {
|
|
221
|
+
expect(tryParse('"hello"')).toBe('hello');
|
|
222
|
+
});
|
|
223
|
+
it('returns the raw string for partially-valid JSON', () => {
|
|
224
|
+
expect(tryParse('{invalid}')).toBe('{invalid}');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
describe('flattenDeep', () => {
|
|
228
|
+
it('flattens a simple nested object', () => {
|
|
229
|
+
const result = flattenDeep({ a: { b: 1 } });
|
|
230
|
+
expect(result).toEqual({ 'a.b': 1 });
|
|
231
|
+
});
|
|
232
|
+
it('leaves a flat object unchanged', () => {
|
|
233
|
+
const result = flattenDeep({ a: 1, b: 2 });
|
|
234
|
+
expect(result).toEqual({ a: 1, b: 2 });
|
|
235
|
+
});
|
|
236
|
+
it('flattens arrays of objects by merging values under a common key', () => {
|
|
237
|
+
const result = flattenDeep({ items: [{ id: 'x' }, { id: 'y' }] });
|
|
238
|
+
expect(result['items.id']).toEqual(['x', 'y']);
|
|
239
|
+
});
|
|
240
|
+
it('keeps a primitive array as-is', () => {
|
|
241
|
+
const result = flattenDeep({ tags: ['a', 'b', 'c'] });
|
|
242
|
+
expect(result.tags).toEqual(['a', 'b', 'c']);
|
|
243
|
+
});
|
|
244
|
+
it('handles an empty object', () => {
|
|
245
|
+
expect(flattenDeep({})).toEqual({});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
describe('formatDate', () => {
|
|
249
|
+
it('returns "?" for a falsy value', () => {
|
|
250
|
+
expect(formatDate(null)).toBe('?');
|
|
251
|
+
});
|
|
252
|
+
it('returns "?" for an empty string', () => {
|
|
253
|
+
expect(formatDate('')).toBe('?');
|
|
254
|
+
});
|
|
255
|
+
it('formats an ISO string as UTC in YYYY/MM/DD HH:mm:ss format', () => {
|
|
256
|
+
// 2021-06-15T12:30:45Z → UTC → "2021/06/15 12:30:45"
|
|
257
|
+
expect(formatDate('2021-06-15T12:30:45Z')).toBe('2021/06/15 12:30:45');
|
|
258
|
+
});
|
|
259
|
+
it('formats a Date object correctly', () => {
|
|
260
|
+
const date = new Date('2023-01-01T00:00:00Z');
|
|
261
|
+
expect(formatDate(date)).toBe('2023/01/01 00:00:00');
|
|
262
|
+
});
|
|
263
|
+
it('formats a unix timestamp (ms) correctly', () => {
|
|
264
|
+
// 1000ms = 1970-01-01T00:00:01Z
|
|
265
|
+
expect(formatDate(1000)).toBe('1970/01/01 00:00:01');
|
|
266
|
+
});
|
|
267
|
+
it('returns "?" for a numeric 0 (treated as falsy by the guard)', () => {
|
|
268
|
+
expect(formatDate(0)).toBe('?');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
describe('twitterShort', () => {
|
|
272
|
+
it('returns "?" for a falsy value', () => {
|
|
273
|
+
expect(twitterShort(null)).toBe('?');
|
|
274
|
+
});
|
|
275
|
+
it('returns "?" for the literal string "?"', () => {
|
|
276
|
+
expect(twitterShort('?')).toBe('?');
|
|
277
|
+
});
|
|
278
|
+
it('returns a non-empty relative string for a recent date', () => {
|
|
279
|
+
const result = twitterShort(new Date().toISOString());
|
|
280
|
+
expect(result).toBeTruthy();
|
|
281
|
+
expect(typeof result).toBe('string');
|
|
282
|
+
});
|
|
283
|
+
it('returns "a few seconds ago" for a date just in the past', () => {
|
|
284
|
+
const recent = new Date(Date.now() - 2000).toISOString();
|
|
285
|
+
expect(twitterShort(recent)).toBe('a few seconds ago');
|
|
286
|
+
});
|
|
287
|
+
it('returns a sensible relative string for a date one year in the past', () => {
|
|
288
|
+
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
|
|
289
|
+
const result = twitterShort(oneYearAgo);
|
|
290
|
+
expect(result).toMatch(/year/);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildViewUrl } from './viewUtils';
|
|
3
|
+
const makeView = (overrides = {}) => ({
|
|
4
|
+
view_id: 'view-1',
|
|
5
|
+
title: 'Test View',
|
|
6
|
+
query: 'howler.status:open',
|
|
7
|
+
sort: 'event.created desc',
|
|
8
|
+
span: 'date.range.1.month',
|
|
9
|
+
type: 'personal',
|
|
10
|
+
owner: 'testuser',
|
|
11
|
+
...overrides
|
|
12
|
+
});
|
|
13
|
+
describe('buildViewUrl', () => {
|
|
14
|
+
it('includes the view_id as the "view" query param', () => {
|
|
15
|
+
const url = buildViewUrl(makeView({ view_id: 'abc-123' }));
|
|
16
|
+
expect(url).toContain('view=abc-123');
|
|
17
|
+
});
|
|
18
|
+
it('starts with /search', () => {
|
|
19
|
+
const url = buildViewUrl(makeView());
|
|
20
|
+
expect(url.startsWith('/search?')).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
it('includes the span param when provided', () => {
|
|
23
|
+
const url = buildViewUrl(makeView({ span: 'date.range.1.week' }));
|
|
24
|
+
expect(url).toContain('span=date.range.1.week');
|
|
25
|
+
});
|
|
26
|
+
it('omits the span param when span is undefined', () => {
|
|
27
|
+
const url = buildViewUrl(makeView({ span: undefined }));
|
|
28
|
+
expect(url).not.toContain('span=');
|
|
29
|
+
});
|
|
30
|
+
it('includes the sort param when provided', () => {
|
|
31
|
+
const url = buildViewUrl(makeView({ sort: 'event.created asc' }));
|
|
32
|
+
expect(url).toContain('sort=event.created+asc');
|
|
33
|
+
});
|
|
34
|
+
it('omits the sort param when sort is undefined', () => {
|
|
35
|
+
const url = buildViewUrl(makeView({ sort: undefined }));
|
|
36
|
+
expect(url).not.toContain('sort=');
|
|
37
|
+
});
|
|
38
|
+
it('builds a complete URL with all fields present', () => {
|
|
39
|
+
const url = buildViewUrl(makeView({ view_id: 'v1', span: 'date.range.1.day', sort: 'event.created desc' }));
|
|
40
|
+
const parsed = new URL(url, 'http://localhost');
|
|
41
|
+
expect(parsed.searchParams.get('view')).toBe('v1');
|
|
42
|
+
expect(parsed.searchParams.get('span')).toBe('date.range.1.day');
|
|
43
|
+
expect(parsed.searchParams.get('sort')).toBe('event.created desc');
|
|
44
|
+
});
|
|
45
|
+
});
|