Handle edge case and add tests for functions
This commit is contained in:
32
client/jest.config.cjs
Normal file
32
client/jest.config.cjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'^../components/DynamicJsonForm$': '<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts',
|
||||||
|
'^../../components/DynamicJsonForm$': '<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts'
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': ['ts-jest', {
|
||||||
|
useESM: true,
|
||||||
|
jsx: 'react-jsx',
|
||||||
|
tsconfig: 'tsconfig.jest.json'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||||
|
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
|
||||||
|
// Exclude directories and files that don't need to be tested
|
||||||
|
testPathIgnorePatterns: [
|
||||||
|
'/node_modules/',
|
||||||
|
'/dist/',
|
||||||
|
'/bin/',
|
||||||
|
'\\.config\\.(js|ts|cjs|mjs)$'
|
||||||
|
],
|
||||||
|
// Exclude the same patterns from coverage reports
|
||||||
|
coveragePathIgnorePatterns: [
|
||||||
|
'/node_modules/',
|
||||||
|
'/dist/',
|
||||||
|
'/bin/',
|
||||||
|
'\\.config\\.(js|ts|cjs|mjs)$'
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "jest --config jest.config.cjs",
|
||||||
|
"test:watch": "jest --config jest.config.cjs --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
"@types/react": "^18.3.10",
|
"@types/react": "^18.3.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
@@ -55,8 +58,11 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.12",
|
"eslint-plugin-react-refresh": "^0.4.12",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
|
"ts-jest": "^29.2.6",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.7.0",
|
"typescript-eslint": "^8.7.0",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
|
|||||||
18
client/src/utils/__mocks__/DynamicJsonForm.ts
Normal file
18
client/src/utils/__mocks__/DynamicJsonForm.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Mock for DynamicJsonForm that only exports the types needed for tests
|
||||||
|
export type JsonValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export type JsonSchemaType = {
|
||||||
|
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, JsonSchemaType>;
|
||||||
|
items?: JsonSchemaType;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default export is not used in the tests
|
||||||
|
export default {};
|
||||||
169
client/src/utils/__tests__/jsonPathUtils.test.ts
Normal file
169
client/src/utils/__tests__/jsonPathUtils.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { updateValueAtPath, getValueAtPath } from '../jsonPathUtils';
|
||||||
|
import { JsonValue } from '../../components/DynamicJsonForm';
|
||||||
|
|
||||||
|
describe('updateValueAtPath', () => {
|
||||||
|
// Basic functionality tests
|
||||||
|
test('returns the new value when path is empty', () => {
|
||||||
|
expect(updateValueAtPath({ foo: 'bar' }, [], 'newValue')).toBe('newValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializes an empty object when input is null/undefined and path starts with a string', () => {
|
||||||
|
expect(updateValueAtPath(null as any, ['foo'], 'bar')).toEqual({ foo: 'bar' });
|
||||||
|
expect(updateValueAtPath(undefined as any, ['foo'], 'bar')).toEqual({ foo: 'bar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializes an empty array when input is null/undefined and path starts with a number', () => {
|
||||||
|
expect(updateValueAtPath(null as any, ['0'], 'bar')).toEqual(['bar']);
|
||||||
|
expect(updateValueAtPath(undefined as any, ['0'], 'bar')).toEqual(['bar']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Object update tests
|
||||||
|
test('updates a simple object property', () => {
|
||||||
|
const obj = { name: 'John', age: 30 };
|
||||||
|
expect(updateValueAtPath(obj, ['age'], 31)).toEqual({ name: 'John', age: 31 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates a nested object property', () => {
|
||||||
|
const obj = { user: { name: 'John', address: { city: 'New York' } } };
|
||||||
|
expect(updateValueAtPath(obj, ['user', 'address', 'city'], 'Boston')).toEqual(
|
||||||
|
{ user: { name: 'John', address: { city: 'Boston' } } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates missing object properties', () => {
|
||||||
|
const obj = { user: { name: 'John' } };
|
||||||
|
expect(updateValueAtPath(obj, ['user', 'address', 'city'], 'Boston')).toEqual(
|
||||||
|
{ user: { name: 'John', address: { city: 'Boston' } } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Array update tests
|
||||||
|
test('updates an array item', () => {
|
||||||
|
const arr = [1, 2, 3, 4];
|
||||||
|
expect(updateValueAtPath(arr, ['2'], 5)).toEqual([1, 2, 5, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extends an array when index is out of bounds', () => {
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
const result = updateValueAtPath(arr, ['5'], 'new') as JsonValue[];
|
||||||
|
|
||||||
|
// Check overall array structure
|
||||||
|
expect(result).toEqual([1, 2, 3, null, null, 'new']);
|
||||||
|
|
||||||
|
// Explicitly verify that indices 3 and 4 contain null, not undefined
|
||||||
|
expect(result[3]).toBe(null);
|
||||||
|
expect(result[4]).toBe(null);
|
||||||
|
|
||||||
|
// Verify these aren't "holes" in the array (important distinction)
|
||||||
|
expect(3 in result).toBe(true);
|
||||||
|
expect(4 in result).toBe(true);
|
||||||
|
|
||||||
|
// Verify the array has the correct length
|
||||||
|
expect(result.length).toBe(6);
|
||||||
|
|
||||||
|
// Verify the array doesn't have holes by checking every index exists
|
||||||
|
expect(result.every((_, index: number) => index in result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates a nested array item', () => {
|
||||||
|
const obj = { users: [{ name: 'John' }, { name: 'Jane' }] };
|
||||||
|
expect(updateValueAtPath(obj, ['users', '1', 'name'], 'Janet')).toEqual(
|
||||||
|
{ users: [{ name: 'John' }, { name: 'Janet' }] }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling tests
|
||||||
|
test('returns original value when trying to update a primitive with a path', () => {
|
||||||
|
const spy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
const result = updateValueAtPath('string', ['foo'], 'bar');
|
||||||
|
expect(result).toBe('string');
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns original array when index is invalid', () => {
|
||||||
|
const spy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(updateValueAtPath(arr, ['invalid'], 4)).toEqual(arr);
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns original array when index is negative', () => {
|
||||||
|
const spy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(updateValueAtPath(arr, ['-1'], 4)).toEqual(arr);
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles sparse arrays correctly by filling holes with null', () => {
|
||||||
|
// Create a sparse array by deleting an element
|
||||||
|
const sparseArr = [1, 2, 3];
|
||||||
|
delete sparseArr[1]; // Now sparseArr is [1, <1 empty item>, 3]
|
||||||
|
|
||||||
|
// Update a value beyond the array length
|
||||||
|
const result = updateValueAtPath(sparseArr, ['5'], 'new') as JsonValue[];
|
||||||
|
|
||||||
|
// Check overall array structure
|
||||||
|
expect(result).toEqual([1, null, 3, null, null, 'new']);
|
||||||
|
|
||||||
|
// Explicitly verify that index 1 (the hole) contains null, not undefined
|
||||||
|
expect(result[1]).toBe(null);
|
||||||
|
|
||||||
|
// Verify this isn't a hole in the array
|
||||||
|
expect(1 in result).toBe(true);
|
||||||
|
|
||||||
|
// Verify all indices contain null (not undefined)
|
||||||
|
expect(result[1]).not.toBe(undefined);
|
||||||
|
expect(result[3]).toBe(null);
|
||||||
|
expect(result[4]).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getValueAtPath', () => {
|
||||||
|
test('returns the original value when path is empty', () => {
|
||||||
|
const obj = { foo: 'bar' };
|
||||||
|
expect(getValueAtPath(obj, [])).toBe(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the value at a simple path', () => {
|
||||||
|
const obj = { name: 'John', age: 30 };
|
||||||
|
expect(getValueAtPath(obj, ['name'])).toBe('John');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the value at a nested path', () => {
|
||||||
|
const obj = { user: { name: 'John', address: { city: 'New York' } } };
|
||||||
|
expect(getValueAtPath(obj, ['user', 'address', 'city'])).toBe('New York');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns default value when path does not exist', () => {
|
||||||
|
const obj = { user: { name: 'John' } };
|
||||||
|
expect(getValueAtPath(obj, ['user', 'address', 'city'], 'Unknown')).toBe('Unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns default value when input is null/undefined', () => {
|
||||||
|
expect(getValueAtPath(null as any, ['foo'], 'default')).toBe('default');
|
||||||
|
expect(getValueAtPath(undefined as any, ['foo'], 'default')).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles array indices correctly', () => {
|
||||||
|
const arr = ['a', 'b', 'c'];
|
||||||
|
expect(getValueAtPath(arr, ['1'])).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns default value for out of bounds array indices', () => {
|
||||||
|
const arr = ['a', 'b', 'c'];
|
||||||
|
expect(getValueAtPath(arr, ['5'], 'default')).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns default value for invalid array indices', () => {
|
||||||
|
const arr = ['a', 'b', 'c'];
|
||||||
|
expect(getValueAtPath(arr, ['invalid'], 'default')).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigates through mixed object and array paths', () => {
|
||||||
|
const obj = { users: [{ name: 'John' }, { name: 'Jane' }] };
|
||||||
|
expect(getValueAtPath(obj, ['users', '1', 'name'])).toBe('Jane');
|
||||||
|
});
|
||||||
|
});
|
||||||
135
client/src/utils/__tests__/schemaUtils.test.ts
Normal file
135
client/src/utils/__tests__/schemaUtils.test.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { generateDefaultValue, formatFieldLabel, validateValueAgainstSchema } from '../schemaUtils';
|
||||||
|
import { JsonSchemaType } from '../../components/DynamicJsonForm';
|
||||||
|
|
||||||
|
describe('generateDefaultValue', () => {
|
||||||
|
test('generates default string', () => {
|
||||||
|
expect(generateDefaultValue({ type: 'string' })).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates default number', () => {
|
||||||
|
expect(generateDefaultValue({ type: 'number' })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates default integer', () => {
|
||||||
|
expect(generateDefaultValue({ type: 'integer' })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates default boolean', () => {
|
||||||
|
expect(generateDefaultValue({ type: 'boolean' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates default array', () => {
|
||||||
|
expect(generateDefaultValue({ type: 'array' })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates default empty object', () => {
|
||||||
|
expect(generateDefaultValue({ type: 'object' })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates default null for unknown types', () => {
|
||||||
|
expect(generateDefaultValue({ type: 'unknown' as any })).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates object with properties', () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
age: { type: 'number' },
|
||||||
|
isActive: { type: 'boolean' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
expect(generateDefaultValue(schema)).toEqual({
|
||||||
|
name: '',
|
||||||
|
age: 0,
|
||||||
|
isActive: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles nested objects', () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
address: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
city: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
expect(generateDefaultValue(schema)).toEqual({
|
||||||
|
user: {
|
||||||
|
name: '',
|
||||||
|
address: {
|
||||||
|
city: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatFieldLabel', () => {
|
||||||
|
test('formats camelCase', () => {
|
||||||
|
expect(formatFieldLabel('firstName')).toBe('First Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats snake_case', () => {
|
||||||
|
expect(formatFieldLabel('first_name')).toBe('First name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats single word', () => {
|
||||||
|
expect(formatFieldLabel('name')).toBe('Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats mixed case with underscores', () => {
|
||||||
|
expect(formatFieldLabel('user_firstName')).toBe('User first Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty string', () => {
|
||||||
|
expect(formatFieldLabel('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateValueAgainstSchema', () => {
|
||||||
|
test('validates string type', () => {
|
||||||
|
expect(validateValueAgainstSchema('test', { type: 'string' })).toBe(true);
|
||||||
|
expect(validateValueAgainstSchema(123, { type: 'string' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates number type', () => {
|
||||||
|
expect(validateValueAgainstSchema(123, { type: 'number' })).toBe(true);
|
||||||
|
expect(validateValueAgainstSchema('test', { type: 'number' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates integer type', () => {
|
||||||
|
expect(validateValueAgainstSchema(123, { type: 'integer' })).toBe(true);
|
||||||
|
expect(validateValueAgainstSchema('test', { type: 'integer' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates boolean type', () => {
|
||||||
|
expect(validateValueAgainstSchema(true, { type: 'boolean' })).toBe(true);
|
||||||
|
expect(validateValueAgainstSchema('test', { type: 'boolean' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates array type', () => {
|
||||||
|
expect(validateValueAgainstSchema([], { type: 'array' })).toBe(true);
|
||||||
|
expect(validateValueAgainstSchema({}, { type: 'array' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates object type', () => {
|
||||||
|
expect(validateValueAgainstSchema({}, { type: 'object' })).toBe(true);
|
||||||
|
expect(validateValueAgainstSchema([], { type: 'object' })).toBe(false);
|
||||||
|
expect(validateValueAgainstSchema('test', { type: 'object' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true for unknown types', () => {
|
||||||
|
expect(validateValueAgainstSchema('anything', { type: 'unknown' as any })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,22 +62,27 @@ function updateArray(
|
|||||||
return array;
|
return array;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newArray = [...array];
|
// Create a dense copy of the array, filling holes with null
|
||||||
|
let newArray: JsonValue[] = [];
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
newArray[i] = i in array ? array[i] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the desired index is out of bounds, build a new array explicitly filled with nulls
|
||||||
|
if (arrayIndex >= newArray.length) {
|
||||||
|
console.warn(`Extending array to index ${arrayIndex}`);
|
||||||
|
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
|
||||||
|
// Copy over the existing elements (now guaranteed to be dense)
|
||||||
|
for (let i = 0; i < newArray.length; i++) {
|
||||||
|
extendedArray[i] = newArray[i];
|
||||||
|
}
|
||||||
|
newArray = extendedArray;
|
||||||
|
}
|
||||||
|
|
||||||
if (restPath.length === 0) {
|
if (restPath.length === 0) {
|
||||||
newArray[arrayIndex] = value;
|
newArray[arrayIndex] = value;
|
||||||
} else {
|
} else {
|
||||||
// Ensure index position exists
|
newArray[arrayIndex] = updateValueAtPath(newArray[arrayIndex], restPath, value);
|
||||||
if (arrayIndex >= array.length) {
|
|
||||||
console.warn(`Extending array to index ${arrayIndex}`);
|
|
||||||
newArray.length = arrayIndex + 1;
|
|
||||||
newArray.fill(null, array.length, arrayIndex);
|
|
||||||
}
|
|
||||||
newArray[arrayIndex] = updateValueAtPath(
|
|
||||||
newArray[arrayIndex],
|
|
||||||
restPath,
|
|
||||||
value
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return newArray;
|
return newArray;
|
||||||
}
|
}
|
||||||
@@ -105,7 +110,6 @@ function updateObject(
|
|||||||
} else {
|
} else {
|
||||||
// Ensure key exists
|
// Ensure key exists
|
||||||
if (!(key in newObj)) {
|
if (!(key in newObj)) {
|
||||||
console.warn(`Creating new key in object: ${key}`);
|
|
||||||
newObj[key] = {};
|
newObj[key] = {};
|
||||||
}
|
}
|
||||||
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
|
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
|
||||||
|
|||||||
10
client/tsconfig.jest.json
Normal file
10
client/tsconfig.jest.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
3381
package-lock.json
generated
3381
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user