Skip to content

Commit f65b9f7

Browse files
authored
test: add Jest config and utils tests (#3031)
* test: add Jest config and utils tests - Added GitHub Actions workflow to run `npm test` on push and pull requests to master. - Introduced Jest configuration (jest.config.js) for unit testing. - Added new test files under js/utils for common utility modules: - AutoBind.test.js - BemHelper.test.js - CommonUtils.test.js - DataTypeConverterUtils.test.js - ExceptionUtils.test.js - Updated package.json to include Jest test script and dependencies. use no-package-json install in ci update deprecated babel plugin
1 parent 047b9fb commit f65b9f7

9 files changed

Lines changed: 291 additions & 7 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ env:
1212
DOCKER_IMAGE: ohdsi/atlas
1313

1414
jobs:
15-
# Build and test the code
15+
# Build the code
1616
build:
1717
# The type of runner that the job will run on
1818
runs-on: ubuntu-latest
@@ -38,6 +38,25 @@ jobs:
3838
- name: Build code
3939
run: npm run build
4040

41+
# Run the test suite
42+
test:
43+
runs-on: ubuntu-latest
44+
needs: build
45+
46+
steps:
47+
- uses: actions/checkout@v2
48+
49+
- name: Use Node.js 18
50+
uses: actions/setup-node@v3
51+
with:
52+
node-version: 18
53+
54+
- name: Install dependencies
55+
run: npm install --no-package-lock
56+
57+
- name: Run tests
58+
run: npm test -- --runInBand
59+
4160
# Check that the docker image builds correctly
4261
# Push to ohdsi/atlas:master for commits on master.
4362
docker:

build/optimize.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const settings = {
2626
out: bundleName,
2727
onBuildRead(moduleName, path, content) {
2828
return babel.transform(content, {
29-
plugins: [ "@babel/plugin-proposal-object-rest-spread" ],
29+
plugins: [ "@babel/plugin-transform-object-rest-spread" ],
3030
presets: [ "@babel/preset-env" ],
3131
}
3232
).code;
@@ -36,4 +36,4 @@ const settings = {
3636
optimize: "none",
3737
};
3838

39-
r.optimize(settings);
39+
r.optimize(settings);

jest.config.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const path = require('path');
2+
const requirejs = require('requirejs');
3+
4+
/** @type {import('jest').Config} */
5+
module.exports = {
6+
testEnvironment: 'node',
7+
testMatch: ['**/*.test.js'],
8+
moduleFileExtensions: ['js', 'json'],
9+
clearMocks: true,
10+
};
11+
12+
requirejs.config({
13+
baseUrl: path.resolve(__dirname, 'js'),
14+
nodeRequire: require,
15+
});
16+
17+
globalThis.requirejsInstance = requirejs;
18+
globalThis.requireAmd = (deps) =>
19+
new Promise((resolve, reject) => {
20+
requirejs(
21+
deps,
22+
(...mods) => resolve(mods.length === 1 ? mods[0] : mods),
23+
reject
24+
);
25+
});

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"build:dev": "node build/optimize.js && npm run compress",
1111
"build:docker": "npm run clean && npm run genversion && npm run build:dev && npm prune --production",
1212
"compress": "terser ./js/assets/bundle/bundle.js -o ./js/assets/bundle/bundle.js -c --source-map",
13-
"genversion": "genversion -s js/version/version.js && r_js -convert js/version js/ && rimraf js/version"
13+
"genversion": "genversion -s js/version/version.js && r_js -convert js/version js/ && rimraf js/version",
14+
"test": "jest --runInBand --watchman=false"
1415
},
1516
"repository": {
1617
"type": "git",
@@ -34,12 +35,13 @@
3435
},
3536
"homepage": "https://github.com/OHDSI/Atlas#readme",
3637
"devDependencies": {
37-
"@babel/core": "7.21.0",
38-
"@babel/plugin-proposal-object-rest-spread": "7.20.7",
38+
"@babel/core": "7.24.7",
39+
"@babel/plugin-transform-object-rest-spread": "7.24.7",
3940
"@babel/polyfill": "7.12.1",
40-
"@babel/preset-env": "7.20.2",
41+
"@babel/preset-env": "7.24.7",
4142
"genversion": "2.2.0",
4243
"html-document": "0.8.1",
44+
"jest": "29.7.0",
4345
"requirejs": "2.3.6",
4446
"rimraf": "2.6.2",
4547
"terser": "5.16.4"

tests/utils/AutoBind.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
let AutoBind;
2+
3+
beforeAll(async () => {
4+
AutoBind = await requireAmd(['utils/AutoBind']);
5+
});
6+
7+
describe('AutoBind', () => {
8+
test('binds prototype methods to the instance context', () => {
9+
class Example extends AutoBind() {
10+
constructor() {
11+
super();
12+
this.counter = 1;
13+
}
14+
15+
increment() {
16+
return ++this.counter;
17+
}
18+
}
19+
20+
const instance = new Example();
21+
const { increment } = instance;
22+
23+
expect(increment()).toBe(2);
24+
expect(instance.counter).toBe(2);
25+
});
26+
27+
test('leaves non-function prototype properties untouched', () => {
28+
class Sample extends AutoBind() {
29+
constructor() {
30+
super();
31+
}
32+
33+
getName() {
34+
return this.componentName;
35+
}
36+
}
37+
38+
Sample.prototype.componentName = 'sample-component';
39+
40+
const instance = new Sample();
41+
42+
expect(instance.componentName).toBe('sample-component');
43+
expect(instance.getName()).toBe('sample-component');
44+
});
45+
});

tests/utils/BemHelper.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
let BemHelper;
2+
3+
beforeAll(async () => {
4+
BemHelper = await requireAmd(['utils/BemHelper']);
5+
});
6+
7+
describe('BemHelper', () => {
8+
test('builds block classes with modifiers and extra entries', () => {
9+
const helper = new BemHelper('atlas-block');
10+
11+
const classes = helper.run({
12+
modifiers: ['active', 'wide'],
13+
extra: ['bg-blue'],
14+
});
15+
16+
expect(classes).toBe('atlas-block atlas-block--active atlas-block--wide bg-blue');
17+
});
18+
19+
test('builds element classes from positional arguments', () => {
20+
const helper = new BemHelper('atlas-block');
21+
22+
const classes = helper.run('item', ['selected', 'highlighted'], 'is-visible');
23+
24+
expect(classes).toBe('atlas-block__item atlas-block__item--selected atlas-block__item--highlighted is-visible');
25+
});
26+
});

tests/utils/CommonUtils.test.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
const appConfigStub = {
2+
commonDataTableOptions: {
3+
pageLength: {
4+
XS: 5,
5+
M: 25,
6+
},
7+
lengthMenu: {
8+
XS: [[5, 10], ['5', '10']],
9+
M: [[10, 25, 50], ['10', '25', '50']],
10+
},
11+
},
12+
};
13+
14+
let commonUtils;
15+
let ko;
16+
let appConfig;
17+
18+
beforeAll(() => {
19+
if (!requirejsInstance.defined('atlas-state')) {
20+
requirejsInstance.define('atlas-state', [], () => ({
21+
selectedConceptsIndex: {},
22+
localeSettings: {},
23+
}));
24+
}
25+
if (!requirejsInstance.defined('appConfig')) {
26+
requirejsInstance.define('appConfig', [], () => appConfigStub);
27+
}
28+
if (!requirejsInstance.defined('pages/Page')) {
29+
requirejsInstance.define('pages/Page', [], () => class MockPage {});
30+
}
31+
if (!requirejsInstance.defined('services/MomentAPI')) {
32+
requirejsInstance.define('services/MomentAPI', [], () => ({
33+
DESIGN_DATE_TIME_FORMAT: 'YYYY-MM-DD H:mm',
34+
formatDateTimeWithFormat: (value, format) => `${value}:${format}`,
35+
}));
36+
}
37+
if (!requirejsInstance.defined('const')) {
38+
requirejsInstance.define('const', [], () => ({
39+
maxEntityNameLength: 255,
40+
}));
41+
}
42+
});
43+
44+
beforeAll(async () => {
45+
[commonUtils, ko, appConfig] = await requireAmd(['utils/CommonUtils', 'knockout', 'appConfig']);
46+
});
47+
48+
describe('CommonUtils', () => {
49+
test('normalizeUrl combines segments without duplicate slashes', () => {
50+
expect(commonUtils.normalizeUrl('/path/', '/to/', 'resource')).toBe('/path/to/resource');
51+
});
52+
53+
test('cartesian produces all permutations across multiple arrays', () => {
54+
const result = commonUtils.cartesian([1, 2], ['a'], ['x', 'y']);
55+
expect(result).toEqual([
56+
[1, 'a', 'x'],
57+
[1, 'a', 'y'],
58+
[2, 'a', 'x'],
59+
[2, 'a', 'y'],
60+
]);
61+
});
62+
63+
test('escapeTooltip escapes both single and double quotes', () => {
64+
const escaped = commonUtils.escapeTooltip(`Bob's "quote"`);
65+
expect(escaped).toBe(`Bob\\'s "quote"`);
66+
});
67+
68+
test('getSelectedConcepts returns copies of selected items without selection flag', () => {
69+
const concepts = ko.observableArray([
70+
{ id: 1, name: 'A', isSelected: () => true },
71+
{ id: 2, name: 'B', isSelected: () => false },
72+
]);
73+
74+
const result = commonUtils.getSelectedConcepts(concepts);
75+
expect(result).toEqual([{ id: 1, name: 'A' }]);
76+
expect(result[0]).not.toHaveProperty('isSelected');
77+
});
78+
79+
test('clearConceptsSelectionState resets observable selection flags', () => {
80+
const first = { id: 1, isSelected: ko.observable(true) };
81+
const second = { id: 2, isSelected: ko.observable(false) };
82+
const conceptList = ko.observableArray([first, second]);
83+
84+
commonUtils.clearConceptsSelectionState(conceptList);
85+
86+
expect(first.isSelected()).toBe(false);
87+
expect(second.isSelected()).toBe(false);
88+
});
89+
90+
test('buildConceptSetItems merges shared options into each entry', () => {
91+
const options = ko.observable({
92+
includeDescendants: true,
93+
isExcluded: false,
94+
});
95+
const concepts = [
96+
{ conceptId: 1 },
97+
{ conceptId: 2 },
98+
];
99+
100+
const items = commonUtils.buildConceptSetItems(concepts, options);
101+
102+
expect(items).toEqual([
103+
{ concept: concepts[0], includeDescendants: true, isExcluded: false },
104+
{ concept: concepts[1], includeDescendants: true, isExcluded: false },
105+
]);
106+
});
107+
108+
test('getTableOptions returns variant specific datatable preferences', () => {
109+
expect(commonUtils.getTableOptions('XS')).toEqual({
110+
pageLength: appConfig.commonDataTableOptions.pageLength.XS,
111+
lengthMenu: appConfig.commonDataTableOptions.lengthMenu.XS,
112+
});
113+
});
114+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
let utils;
2+
3+
beforeAll(async () => {
4+
utils = await requireAmd(['utils/DataTypeConverterUtils']);
5+
});
6+
7+
describe('DataTypeConverterUtils', () => {
8+
test('splits comma delimited values into arrays and numbers', () => {
9+
expect(utils.commaDelimitedListToArray('A,B,C')).toEqual(['A', 'B', 'C']);
10+
expect(utils.commaDelimitedListToArray('')).toEqual([]);
11+
12+
expect(utils.commaDelimitedListToNumericArray('1,2,3')).toEqual([1, 2, 3]);
13+
});
14+
15+
test('converts comma delimited percents into normalized fractions', () => {
16+
expect(utils.commaDelimitedListToPercentArray('50,1,0.25')).toEqual([0.5, 0.01, 0.25]);
17+
expect(utils.percentArrayToCommaDelimitedList([0.5, 0.01, 2])).toBe('50,1,2');
18+
});
19+
20+
test('converts to and from YYYYMMDD R date format', () => {
21+
const date = new Date(2023, 4, 7); // Month is 0-based
22+
expect(utils.convertToDateForR(date)).toBe('20230507');
23+
24+
const converted = utils.convertFromRDateToDate('20230507');
25+
expect(converted.getFullYear()).toBe(2023);
26+
expect(converted.getMonth()).toBe(4);
27+
expect(converted.getDate()).toBe(7);
28+
});
29+
});

tests/utils/ExceptionUtils.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
let exceptionUtils;
2+
3+
beforeAll(async () => {
4+
exceptionUtils = await requireAmd(['utils/ExceptionUtils']);
5+
});
6+
7+
describe('ExceptionUtils', () => {
8+
test('translates 403 errors into a permission message', () => {
9+
expect(exceptionUtils.translateException({ status: 403 })).toBe('You have insufficient permissions!');
10+
});
11+
12+
test('translates other errors into a generic message', () => {
13+
expect(exceptionUtils.translateException({ status: 500 })).toBe('Oops, Something went wrong!');
14+
});
15+
16+
test('extracts messages from server payloads', () => {
17+
const payload = { data: { payload: { message: 'Something broke' } } };
18+
expect(exceptionUtils.extractServerMessage(payload)).toBe('Something broke');
19+
});
20+
21+
test('falls back to default server message when payload missing', () => {
22+
expect(exceptionUtils.extractServerMessage({})).toBe('Error! Please see server logs for details.');
23+
});
24+
});

0 commit comments

Comments
 (0)